-
Notifications
You must be signed in to change notification settings - Fork 0
feat(settings): files-based layered settings foundation (#572 part 1 v2) #592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| --- | ||
| '@conciv/core': patch | ||
| --- | ||
|
|
||
| Files-based layered settings foundation: a namespaced settings registry in `@conciv/protocol` | ||
| (`appearance.scheme`), a `settings` oRPC group (`get`/`set`/`clear`/`applyGlobally`/`history`) | ||
| resolving the project layer under `<stateRoot>/.conciv/` over the global layer under `~/.conciv/` | ||
| over registry defaults. Each layer honors `settings.jsonc` if present, otherwise `settings.json`; | ||
| comments are supported in `.jsonc` and survive programmatic writes. Includes per-layer content | ||
| revisions for optimistic concurrency, atomic persistence, a cross-process lock on the shared global | ||
| file, an append-only history sidecar, and live settings-changed events broadcast to every attached | ||
| session. | ||
|
|
||
| Adds dependencies on `c12`, `jsonc-parser`, `write-file-atomic` and `proper-lockfile`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ import { | |
| import {BundlerConfigSchema, ModuleNodeSchema} from '@conciv/protocol/bundler-types' | ||
| import {SessionCapturesSchema} from '@conciv/protocol/element-capture-types' | ||
| import {TOOL_ICON_KEYS} from '@conciv/protocol/tool-icon-types' | ||
| import {SettingsHistoryEntrySchema, SettingsReadSchema, SettingsScopeSchema} from '@conciv/protocol/settings-types' | ||
| import {DraftRowSchema, MarkerRowSchema, SessionMetaSchema} from './rows.js' | ||
|
|
||
| const StreamChunkSchema = z.custom<StreamChunk>((value) => typeof value === 'object' && value !== null) | ||
|
|
@@ -36,6 +37,18 @@ export const ChatSendInput = SessionIdInput.extend({ | |
| const Ok = z.object({ok: z.literal(true)}) | ||
| const SendAccepted = z.object({ok: z.literal(true), runId: z.string()}) | ||
| const NavigationWriteResult = z.object({ok: z.literal(true), applied: z.boolean()}) | ||
| const SettingsWriteResult = z.object({ok: z.literal(true), opId: z.string()}) | ||
| const settingsWriteErrors = { | ||
| UNKNOWN_KEY: {message: 'no registered setting with that key'}, | ||
| INVALID_VALUE: {message: 'the value failed the registry schema for this key'}, | ||
| REVISION_CONFLICT: { | ||
| status: 409, | ||
| message: 'the settings file changed since it was read; refetch and retry', | ||
| data: z.object({scope: SettingsScopeSchema, revision: z.string()}), | ||
| }, | ||
| LAYER_UNPARSEABLE: {message: 'the settings file does not parse; fix it before writing'}, | ||
| LOCK_TIMEOUT: {message: 'another process is holding the global settings lock'}, | ||
| } | ||
| const notFound = {NOT_FOUND: {message: 'session not found'}} | ||
| const noBundler = {NO_BUNDLER: {message: 'no bundler bridge'}} | ||
| const approvalDenied = {APPROVAL_DENIED: {message: 'the call was not approved'}} | ||
|
|
@@ -115,6 +128,44 @@ export const contract = { | |
| get: oc.output(NavigationWriteSchema.nullable()), | ||
| set: oc.input(NavigationWriteSchema).output(NavigationWriteResult), | ||
| }, | ||
| settings: { | ||
| get: oc.output(SettingsReadSchema), | ||
| set: oc | ||
| .errors(settingsWriteErrors) | ||
| .input( | ||
| z.object({ | ||
| key: z.string(), | ||
| value: z.unknown(), | ||
| scope: SettingsScopeSchema, | ||
| expectedRevision: z.string(), | ||
| }), | ||
| ) | ||
| .output(SettingsWriteResult), | ||
|
Comment on lines
+133
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: In Zod 4, the behavior of z.unknown (and z.any) inside a z.object is nuanced, differing between static type inference and runtime validation [1][2]. Static Type Inference: In Zod 4, TypeScript correctly infers these properties as required [3]. Unlike in some previous versions or configurations, z.infer will treat the key as mandatory, and TypeScript will issue an error if the property is missing from an object [3]. Runtime Validation: At runtime, Zod continues to treat properties defined with z.unknown or z.any as optional [2]. If the property is missing from the input object, Zod will not throw a validation error; instead, it will pass undefined as the value for that key [2]. This occurs because Zod's internal parsing logic treats any schema that can accept undefined (which unknown and any do) as optional during execution [1][2]. If you need to enforce that a property is present at runtime, you can use the.nonoptional method on the schema: const schema = z.object({ field: z.unknown.nonoptional }); schema.parse({}); // Throws a validation error [2] Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- contract.ts ---'
cat -n packages/contract/src/contract.ts | sed -n '120,170p'
printf '%s\n' '--- settings service references ---'
rg -n -C 8 'function validate|validate|planEdits|value === undefined|applyGlobally|settings\.set|settings\.clear' packages/core/src/settings packages/contract/src
printf '%s\n' '--- Zod version and relevant lockfile entries ---'
rg -n '"zod"|zod@|/zod/' package.json packages/*/package.json pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null | head -80Repository: conciv-dev/conciv Length of output: 21238 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- contract.ts ---'
cat -n packages/contract/src/contract.ts | sed -n '120,170p'
printf '%s\n' '--- settings service references ---'
rg -n -C 8 'function validate|validate|planEdits|value === undefined|applyGlobally|settings\.set|settings\.clear' packages/core/src/settings packages/contract/src
printf '%s\n' '--- Zod version and lockfile entries ---'
rg -n '"zod"|zod@|/zod/' package.json packages/*/package.json pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null | head -100Repository: conciv-dev/conciv Length of output: 23791 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
contract = Path('packages/contract/src/contract.ts').read_text()
service = Path('packages/core/src/settings/service.ts').read_text()
for label, text, needles in [
('contract', contract, ['value: z.unknown()', 'applyGlobally']),
('service', service, ['value === undefined', 'planEdits']),
]:
print(f'--- {label} ---')
for needle in needles:
print(f'{needle}: {text.count(needle)} occurrence(s)')
PY
cat -n packages/core/src/settings/service.ts | sed -n '90,125p;200,230p'
rg -n '"zod"|zod:' package.json packages/*/package.json pnpm-lock.yaml 2>/dev/null | head -60Repository: conciv-dev/conciv Length of output: 6201 🏁 Script executed: #!/bin/bash
set -eu
cat -n packages/core/src/settings/service.ts | sed -n '270,315p'Repository: conciv-dev/conciv Length of output: 2617 Require At runtime, Zod 4 accepts an omitted 🤖 Prompt for AI Agents |
||
| clear: oc | ||
| .errors(settingsWriteErrors) | ||
| .input(z.object({key: z.string(), scope: SettingsScopeSchema, expectedRevision: z.string()})) | ||
| .output(SettingsWriteResult), | ||
| applyGlobally: oc | ||
| .errors(settingsWriteErrors) | ||
| .input( | ||
| z.object({ | ||
| key: z.string(), | ||
| value: z.unknown(), | ||
| expectedRevisions: z.object({project: z.string(), global: z.string()}), | ||
| }), | ||
| ) | ||
| .output(SettingsWriteResult), | ||
| reset: oc | ||
| .errors(settingsWriteErrors) | ||
| .input( | ||
| z.object({ | ||
| key: z.string(), | ||
| expectedRevisions: z.object({project: z.string(), global: z.string()}), | ||
| }), | ||
| ) | ||
| .output(SettingsWriteResult), | ||
| history: oc.input(z.object({key: z.string()})).output(z.array(SettingsHistoryEntrySchema)), | ||
| }, | ||
| chat: { | ||
| subscribe: oc.input(SessionIdInput).output(eventIterator(StreamChunkSchema)), | ||
| send: oc | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,9 +87,13 @@ | |
| "@tanstack/ai-isolate-quickjs": "catalog:", | ||
| "@tanstack/ai-sandbox": "0.3.0", | ||
| "@tanstack/ai-sandbox-local-process": "^0.2.1", | ||
| "c12": "~3.3.4", | ||
| "drizzle-orm": "1.0.0-rc.4", | ||
| "hono": "^4.12.0", | ||
| "jsonc-parser": "^3.3.1", | ||
| "lucide-solid": "^1.18.0", | ||
| "proper-lockfile": "^4.1.2", | ||
| "write-file-atomic": "^8.0.0", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check write-file-atomic versions, bundled types, and the matching `@types` major.
set -euo pipefail
curl -s https://registry.npmjs.org/write-file-atomic | jq '{latest: .["dist-tags"].latest, majors: ([.versions | keys[]] | map(split(".")[0]) | unique)}'
curl -s https://registry.npmjs.org/write-file-atomic/latest | jq '{version, types, typings, exports}'
curl -s https://registry.npmjs.org/@types/write-file-atomic | jq '{latest: .["dist-tags"].latest}'Repository: conciv-dev/conciv Length of output: 373 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- package manifest ---'
sed -n '80,105p' packages/core/package.json
printf '%s\n' '--- layer-store call sites ---'
rg -n -C 8 'writeFileAtomic|write-file-atomic' packages/core/src/settings/layer-store.ts
printf '%s\n' '--- repository references ---'
rg -n 'write-file-atomic|`@types/write-file-atomic`' --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' .
printf '%s\n' '--- published package metadata ---'
curl -sSf https://registry.npmjs.org/write-file-atomic/8.0.0 |
jq '{version,types,typings,files,dependencies,engines,exports}'
curl -sSf https://registry.npmjs.org/@types/write-file-atomic/4.0.3 |
jq '{version,types,typings,dependencies,peerDependencies,deprecated}'Repository: conciv-dev/conciv Length of output: 3575 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- write-file-atomic 8.0.0 declaration and implementation files ---'
tarball=$(curl -sSf https://registry.npmjs.org/write-file-atomic/8.0.0 | jq -r '.dist.tarball')
curl -sSfL "$tarball" | tar -xzO package/package.json
curl -sSfL "$tarball" | tar -tzf - >/tmp/write-file-atomic-8-files.txt
cat /tmp/write-file-atomic-8-files.txt
for file in package/index.js package/index.d.ts; do
if curl -sSfL "$tarball" | tar -xzOf - "$file" 2>/dev/null; then
:
else
printf '%s\n' "missing: $file"
fi
done
printf '%s\n' '--- `@types/write-file-atomic` 4.0.3 declaration ---'
types_tarball=$(curl -sSf https://registry.npmjs.org/@types/write-file-atomic/4.0.3 | jq -r '.dist.tarball')
curl -sSfL "$types_tarball" | tar -xzOf - package/index.d.ts
printf '%s\n' '--- lockfile dependency scopes ---'
sed -n '1725,1775p' pnpm-lock.yamlRepository: conciv-dev/conciv Length of output: 1900 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import io
import json
import tarfile
import urllib.request
def package_files(url):
data = urllib.request.urlopen(url).read()
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
return {m.name: archive.extractfile(m).read().decode('utf-8', 'replace')
for m in archive.getmembers() if m.isfile()}
for label, metadata_url in [
('write-file-atomic@8.0.0', 'https://registry.npmjs.org/write-file-atomic/8.0.0'),
('`@types/write-file-atomic`@4.0.3', 'https://registry.npmjs.org/@types/write-file-atomic/4.0.3'),
]:
metadata = json.load(urllib.request.urlopen(metadata_url))
files = package_files(metadata['dist']['tarball'])
print(f'--- {label} files ---')
print('\n'.join(sorted(files)))
for name, content in files.items():
if name.endswith(('/lib/index.js', '/index.d.ts', '/README.md')):
print(f'--- {name} ---')
print(content)
PY
printf '%s\n' '--- repository Node engine declarations ---'
rg -n -C 3 '"engines"|"node":|node-version|NODE_VERSION' --glob 'package.json' --glob '*.yml' --glob '*.yaml' .Repository: conciv-dev/conciv Length of output: 2442 🏁 Script executed: #!/bin/bash
set -euo pipefail
get_tarball() {
curl -sSfL "$1" | tar -tzf - >/dev/stdout
}
printf '%s\n' '--- write-file-atomic 8.0.0 files ---'
write_url=$(curl -sSf https://registry.npmjs.org/write-file-atomic/8.0.0 | jq -r '.dist.tarball')
get_tarball "$write_url"
printf '%s\n' '--- write-file-atomic 8.0.0 implementation ---'
curl -sSfL "$write_url" | tar -xzOf - package/lib/index.js
printf '%s\n' '--- write-file-atomic 8.0.0 README API ---'
curl -sSfL "$write_url" | tar -xzOf - package/README.md | rg -n -C 5 'sync|fsync|encoding|writeFileAtomic'
printf '%s\n' '`@types/write-file-atomic` 4.0.3 files'
types_url=$(curl -sSf https://registry.npmjs.org/@types/write-file-atomic/4.0.3 | jq -r '.dist.tarball')
get_tarball "$types_url"
printf '%s\n' '`@types/write-file-atomic` 4.0.3 declarations'
types_file=$(get_tarball "$types_url" | rg 'index\.d\.ts$' | head -n 1)
curl -sSfL "$types_url" | tar -xzOf - "$types_file"Repository: conciv-dev/conciv Length of output: 311 🏁 Script executed: #!/bin/bash
set -u
write_url=$(curl -sSf https://registry.npmjs.org/write-file-atomic/8.0.0 | jq -r '.dist.tarball')
types_url=$(curl -sSf https://registry.npmjs.org/@types/write-file-atomic/4.0.3 | jq -r '.dist.tarball')
printf '%s\n' '--- write-file-atomic 8.0.0 files ---'
curl -sSfL "$write_url" | tar -tzf -
printf '%s\n' '--- write-file-atomic 8.0.0 implementation ---'
curl -sSfL "$write_url" | tar -xzOf - package/lib/index.js
printf '%s\n' '--- write-file-atomic 8.0.0 README API ---'
curl -sSfL "$write_url" | tar -xzOf - package/README.md | grep -n -E -C 5 'sync|fsync|encoding|writeFileAtomic' || true
printf '%s\n' '`@types/write-file-atomic` 4.0.3 files'
curl -sSfL "$types_url" | tar -tzf -
printf '%s\n' '`@types/write-file-atomic` 4.0.3 declarations'
curl -sSfL "$types_url" | tar -xzOf - package/index.d.tsRepository: conciv-dev/conciv Length of output: 10229 🏁 Script executed: #!/bin/bash
set -euo pipefail
types_url=$(curl -sSf https://registry.npmjs.org/@types/write-file-atomic/4.0.3 | jq -r '.dist.tarball')
printf '%s\n' '--- `@types/write-file-atomic` 4.0.3 declaration ---'
curl -sSfL "$types_url" | tar -xzOf - write-file-atomic/index.d.ts
printf '%s\n' '--- `@types/write-file-atomic` package metadata ---'
curl -sSfL "$types_url" | tar -xzOf - write-file-atomic/package.json
printf '%s\n' '--- runtime and development dependency declarations ---'
sed -n '90,115p' packages/core/package.json
printf '%s\n' '--- repository runtime requirements ---'
rg -n -C 3 '"engines"|"node":|node-version|NODE_VERSION' --glob 'package.json' --glob '*.yml' --glob '*.yaml' .Repository: conciv-dev/conciv Length of output: 5836 Align the Node engine requirement before adopting
🤖 Prompt for AI Agents |
||
| "zod": "^4.4.3" | ||
| }, | ||
| "devDependencies": { | ||
|
|
@@ -102,6 +106,8 @@ | |
| "@tanstack/ai-mcp": "catalog:", | ||
| "@tanstack/ai-sandbox-local-process": "^0.2.1", | ||
| "@types/node": "^22.19.21", | ||
| "@types/proper-lockfile": "^4.1.4", | ||
| "@types/write-file-atomic": "^4.0.3", | ||
| "@types/ws": "^8.18.1", | ||
| "@vitest/coverage-v8": "^4.1.8", | ||
| "get-port": "^7.1.0", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import {homedir} from 'node:os' | ||
| import {join} from 'node:path' | ||
| import {CONCIV_STATE_DIR} from '@conciv/protocol/state-types' | ||
|
|
||
| export function concivHomeDir(): string { | ||
| return join(homedir(), CONCIV_STATE_DIR) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import {appendFileSync, mkdirSync, readFileSync} from 'node:fs' | ||
| import {dirname} from 'node:path' | ||
| import {SettingsHistoryEntrySchema, type SettingsHistoryEntry} from '@conciv/protocol/settings-types' | ||
| import {logError} from '../lib/debug.js' | ||
|
|
||
| export function appendHistory(path: string, entry: SettingsHistoryEntry): void { | ||
| try { | ||
| mkdirSync(dirname(path), {recursive: true}) | ||
| appendFileSync(path, `${JSON.stringify(entry)}\n`) | ||
| } catch (error) { | ||
| logError(`[core] the settings history sidecar could not be appended: ${String(error)}`) | ||
| } | ||
| } | ||
|
|
||
| function parseLine(line: string): SettingsHistoryEntry | null { | ||
| try { | ||
| const parsed = SettingsHistoryEntrySchema.safeParse(JSON.parse(line)) | ||
| return parsed.success ? parsed.data : null | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| export function readHistory(path: string, key: string): SettingsHistoryEntry[] { | ||
| try { | ||
| return readFileSync(path, 'utf8') | ||
| .split('\n') | ||
| .toReversed() | ||
| .flatMap((line) => { | ||
| if (line.trim() === '') return [] | ||
| const entry = parseLine(line) | ||
| return entry !== null && entry.key === key ? [entry] : [] | ||
| }) | ||
| } catch { | ||
| return [] | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove
c12from the dependency list.The changeset states the change adds a dependency on
c12. The PR description states the opposite: c12 is not used for reading, because it does not support the requiredsettings.jsonJSONC handling and per-layer error isolation. This line becomes an incorrect public release note.📝 Proposed fix
If
c12is in fact still declared inpackages/core/package.json, remove the unused dependency instead.📝 Committable suggestion
🤖 Prompt for AI Agents