Skip to content

Commit 1622028

Browse files
authored
Merge pull request #473 from atomantic/claim/extract-layout-doc-io-helper-from-migrations
refactor([extract-layout-doc-io-helper-from-migrations]): extract readLayoutsDoc/writeLayoutsDoc helper
2 parents a6ebed9 + 7cd30bf commit 1622028

7 files changed

Lines changed: 127 additions & 66 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- `data/history.json` is now `data/history.jsonl`. Migration 037 converts the legacy `{ entries: [...] }` wrapper into one JSON record per line, renames the old file to `history.json.bak-037`, and the history service now appends new actions instead of rewriting the whole file on each log. `server/lib/fileUtils.js` also exposes shared `appendJSONLine`, `readJSONLines`, and `writeJSONLines` helpers for other JSONL logs.
2323
- `/work <task>` Claude Code slash command (`.claude/commands/work.md`) — slugifies the task, spins up a fresh git worktree under `.claude/worktrees/<slug>/` branched off local `main` (NOT current HEAD), `cd`s into it, carries out the work without disturbing the originating checkout's uncommitted edits / branch / HEAD, and on completion always chains `/simplify``/do:review --with codex,gemini``/do:pr --review-with copilot` so codex + gemini findings are addressed locally *before* the PR opens (otherwise Copilot kicks in immediately on unreviewed code and burns review cycles). Branch / dir collision auto-suffixes with `-2`, `-3`, etc. `.claude/worktrees/` is already gitignored.
2424
- **Fork-aware update flow.** The PortOS update tab now detects whether the local clone's `origin` remote points at upstream `atomantic/PortOS` or at a personal fork (`server/lib/gitRemote.js` parses SCP-style + HTTPS + ssh:// remote URLs, case-insensitive owner/repo match). When running from a fork the Update tab swaps the "Update Now" button for three explicit choices: **Sync Fork & Update** (runs `gh repo sync <owner>/<fork> --source atomantic/PortOS --branch main` then proceeds with the local update — fast-forward only, so it refuses to clobber divergent fork commits), **Sync Fork Only** (sync without applying), and **Update from Fork As-Is** (skip sync, pull from your fork's origin — useful if you already merged upstream into your fork via your own workflow). The server `/api/update/execute` endpoint now gates fork runs behind either a fresh `lastForkSync` record (≤10 min, same fullName) or an explicit `acknowledgeFork: true` to avoid the silent "I clicked Update and nothing happened" failure mode when a fork's main is behind upstream. The release-check still polls `atomantic/PortOS` so fork users continue to see upstream version notifications. `POST /api/update/sync-fork` exposes the gh-sync action; a 409 with `FORK_DIVERGED` is returned when the fork's main has commits not on upstream, with guidance pointing the user at PRs / feature branches / the explicit `--force` escape hatch they can run from a terminal. `update.sh` / `update.ps1` now log the active origin URL so users can confirm in the update log which repo they actually pulled from.
25+
- **[extract-layout-doc-io-helper-from-migrations] Shared helper for dashboard-layout seeding migrations.** Internal cleanup: the migrations that add new built-in dashboard widgets/layouts to existing installs now share one read/guard/write helper instead of each copying the same boilerplate, so future layout migrations stay consistent.
2526

2627
## Fixed
2728

PLAN.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel
66

77
- [ ] [extract-compare-helpers-once-eight-callers] **Extract `equalByKeys` / `equalListByKeys` once `useAutoRefetch`'s `compare` reaches ~8 widget callers.** With the dashboard rollout (PR #421) there are now 5 widget callers (Backup, Chief of Staff, Decision Log, Goal Progress, Proactive Alerts) on top of EpisodeVideoStage / Brain / Digital Twin from PR #425, using two emerging shapes — (a) scalar-object key equality (BackupWidget status, DecisionLogWidget last24Hours, ProactiveAlertsWidget counts) and (b) array equality by length + per-item key tuple (BackupWidget snapshots, GoalProgressWidget goals, ProactiveAlertsWidget alerts list, DecisionLogWidget impactfulDecisions). Inline forms are noisy but each has an explanatory comment that an `equalByKeys(prev, next, ['status', 'lastRun', …])` would lose. Defer until enough more callers appear that the abstraction pays for itself, then add helpers to `client/src/lib/compareHelpers.js` (register in `index.js` + `README.md`) and migrate the flat callers — leave CosDashboardWidget's nested-optional-chain shape inline. Surfaced by /simplify reuse review during `[useautorefetch-compare-caller-rollout-widgets]` claim 2026-05-21.
88
- [ ] [codex5-onboarding-capability-map] **[P2][ONBOARDING]** Capability map of connected systems. One page showing each integration's status: Providers (per-provider configured/available/throttled), Calendar, Brain/memory embeddings, Voice (mic + TTS), Tailscale + HTTPS, Genome/health imports, Telegram/messages, App registry/PM2. Each row links to the relevant settings page. Doubles as a setup checklist and a runtime health overview.
9-
- [ ] [extract-layout-doc-io-helper-from-migrations] **Extract `readLayoutsDoc` / `writeLayoutsDoc` helper for migrations.** Migrations 029 and 030 both repeat the same `readFile → JSON.parse → Array.isArray(doc.layouts) guard → writeFile` shell with near-identical logging strings. `scripts/migrations/_lib.js` already exists as the shared library for prompt-replace migrations — add a layouts-doc helper there so a future migration that touches `data/dashboard-layouts.json` doesn't fork the pattern a third time. Surfaced by /simplify quality review during `[codex5-dashboard-intent-layouts]` claim 2026-05-20.
109
- [ ] [migrate-two-remaining-local-formatters] **Migrate two remaining local formatters surfaced during the module-discovery PR.** Two additional local format definitions were found that aren't in the original tracker: (1) `client/src/components/calendar/EventDetail.jsx:10` defines `formatDateTime(dateStr, isAllDay)` with an all-day branch the shared `formatDateTime` lacks — extend the shared helper to take `{ allDay: true }` or keep this local and rename to `formatEventDateTime`. (2) `client/src/components/cos/tabs/TaskItem.jsx:78` defines `formatDurationMin(mins)` with a `~` approximation prefix (`~3h 30m`) — extend shared `formatDurationMin` with an `{ approximate: true }` option. Low priority; visual semantics need to be preserved exactly.
1110
- [ ] [wire-proactive-cos-speech-to-real-triggers] **Wire proactive CoS speech to real triggers.** Plumbing landed (`POST /api/voice/speak` + `voice:speak` socket event); hook to high-severity `errorEvents`, `task:ready`, and `notificationEvents` with per-source rate-limits.
1211
- [ ] [optimize-voice-ui-index-text-payload-lazy-only-run] **Optimize `voice:ui:index` text payload.** Lazy: only run `extractVisibleText` when server requests via `voice:ui:read-request`. Keep current behavior as fallback.

scripts/migrations/029-seed-network-exposure-widget.js

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
* come back.
2222
*/
2323

24-
import { readFile, writeFile } from 'fs/promises';
25-
import { join } from 'path';
24+
import { readLayoutsDoc, writeLayoutsDoc } from './_lib.js';
2625

2726
const WIDGET_ID = 'network-exposure';
2827
const WIDGET_W = 3;
@@ -75,23 +74,9 @@ function applyToLayout(layout) {
7574

7675
export default {
7776
async up({ rootDir }) {
78-
const path = join(rootDir, 'data', 'dashboard-layouts.json');
79-
const raw = await readFile(path, 'utf-8').catch((err) => {
80-
if (err.code === 'ENOENT') return null;
81-
throw err;
82-
});
83-
if (raw == null) {
84-
console.log(`📦 migration 029: no dashboard-layouts.json yet — fresh install will seed from defaults.`);
85-
return { updated: 0, reason: 'no-state' };
86-
}
87-
let doc;
88-
try { doc = JSON.parse(raw); } catch {
89-
console.log(`📦 migration 029: dashboard-layouts.json unreadable — skipping.`);
90-
return { updated: 0, reason: 'unreadable' };
91-
}
92-
if (!doc || !Array.isArray(doc.layouts)) {
93-
return { updated: 0, reason: 'no-layouts-array' };
94-
}
77+
const result = await readLayoutsDoc({ rootDir, label: 'migration 029' });
78+
if (!result.ok) return { updated: 0, reason: result.reason };
79+
const { doc, path } = result;
9580

9681
let touched = 0;
9782
for (const layout of doc.layouts) {
@@ -104,7 +89,7 @@ export default {
10489
return { updated: 0, reason: 'already-applied' };
10590
}
10691

107-
await writeFile(path, JSON.stringify(doc, null, 2));
92+
await writeLayoutsDoc(path, doc);
10893
console.log(`📦 migration 029: seeded network-exposure widget into ${touched} built-in layout(s).`);
10994
return { updated: touched };
11095
},

scripts/migrations/030-seed-intent-dashboard-layouts.js

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,14 @@
2020
* re-seed it. This matches the behavior of the fresh-install path.
2121
*/
2222

23-
import { readFile, writeFile } from 'fs/promises';
24-
import { join } from 'path';
2523
import { INTENT_LAYOUTS } from '../../server/services/dashboardLayouts.js';
24+
import { readLayoutsDoc, writeLayoutsDoc } from './_lib.js';
2625

2726
export default {
2827
async up({ rootDir }) {
29-
const path = join(rootDir, 'data', 'dashboard-layouts.json');
30-
const raw = await readFile(path, 'utf-8').catch((err) => {
31-
if (err.code === 'ENOENT') return null;
32-
throw err;
33-
});
34-
if (raw == null) {
35-
console.log(`📦 migration 030: no dashboard-layouts.json yet — fresh install will seed from defaults.`);
36-
return { updated: 0, reason: 'no-state' };
37-
}
38-
let doc;
39-
try { doc = JSON.parse(raw); } catch {
40-
console.log(`📦 migration 030: dashboard-layouts.json unreadable — skipping.`);
41-
return { updated: 0, reason: 'unreadable' };
42-
}
43-
if (!doc || !Array.isArray(doc.layouts)) {
44-
return { updated: 0, reason: 'no-layouts-array' };
45-
}
28+
const result = await readLayoutsDoc({ rootDir, label: 'migration 030' });
29+
if (!result.ok) return { updated: 0, reason: result.reason };
30+
const { doc, path } = result;
4631

4732
const existingIds = new Set(doc.layouts.map((l) => l?.id).filter(Boolean));
4833
const toAdd = INTENT_LAYOUTS.filter((l) => !existingIds.has(l.id));
@@ -52,7 +37,7 @@ export default {
5237
}
5338

5439
doc.layouts.push(...toAdd.map((l) => ({ ...l, builtIn: true })));
55-
await writeFile(path, JSON.stringify(doc, null, 2));
40+
await writeLayoutsDoc(path, doc);
5641
console.log(`📦 migration 030: seeded ${toAdd.length} intent layout(s) (${toAdd.map((l) => l.id).join(', ')}).`);
5742
return { updated: toAdd.length };
5843
},

scripts/migrations/033-seed-quick-image-widget.js

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
* already present and skip.
1414
*/
1515

16-
import { readFile, writeFile } from 'fs/promises';
17-
import { join } from 'path';
16+
import { readLayoutsDoc, writeLayoutsDoc } from './_lib.js';
1817

1918
const WIDGET_ID = 'quick-image';
2019
const WIDGET_W = 3;
@@ -83,23 +82,9 @@ function applyToLayout(layout) {
8382

8483
export default {
8584
async up({ rootDir }) {
86-
const path = join(rootDir, 'data', 'dashboard-layouts.json');
87-
const raw = await readFile(path, 'utf-8').catch((err) => {
88-
if (err.code === 'ENOENT') return null;
89-
throw err;
90-
});
91-
if (raw == null) {
92-
console.log(`📦 migration 033: no dashboard-layouts.json yet — fresh install will seed from defaults.`);
93-
return { updated: 0, reason: 'no-state' };
94-
}
95-
let doc;
96-
try { doc = JSON.parse(raw); } catch {
97-
console.log(`📦 migration 033: dashboard-layouts.json unreadable — skipping.`);
98-
return { updated: 0, reason: 'unreadable' };
99-
}
100-
if (!doc || !Array.isArray(doc.layouts)) {
101-
return { updated: 0, reason: 'no-layouts-array' };
102-
}
85+
const result = await readLayoutsDoc({ rootDir, label: 'migration 033' });
86+
if (!result.ok) return { updated: 0, reason: result.reason };
87+
const { doc, path } = result;
10388

10489
let touched = 0;
10590
for (const layout of doc.layouts) {
@@ -112,7 +97,7 @@ export default {
11297
return { updated: 0, reason: 'already-applied' };
11398
}
11499

115-
await writeFile(path, JSON.stringify(doc, null, 2));
100+
await writeLayoutsDoc(path, doc);
116101
console.log(`📦 migration 033: seeded quick-image widget into ${touched} built-in layout(s).`);
117102
return { updated: touched };
118103
},

scripts/migrations/_lib.js

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/**
2-
* Shared scaffolding for hash-driven prompt-replace migrations.
2+
* Shared scaffolding for migrations. Two families live here:
33
*
4-
* Every prompt-replace migration from 003 onward uses
5-
* `makePromptReplaceMigration` to collapse onto ~50 lines (hash table + label
6-
* + customized-skip hint).
4+
* 1. Hash-driven prompt-replace migrations — every one from 003 onward uses
5+
* `makePromptReplaceMigration` to collapse onto ~50 lines (hash table +
6+
* label + customized-skip hint).
7+
* 2. Dashboard-layout seeding migrations — `readLayoutsDoc` /
8+
* `writeLayoutsDoc` collapse the read → JSON.parse → `Array.isArray`
9+
* guard → write shell shared by every migration that mutates built-in
10+
* layouts in `data/dashboard-layouts.json` (029, 030, 033, …).
711
*
812
* The runner (`scripts/run-migrations.js`) explicitly skips `_`-prefixed
913
* files so this module is never imported as a migration.
@@ -179,3 +183,46 @@ export function makePromptReplaceMigration({
179183

180184
return { applyMigration, up };
181185
}
186+
187+
/**
188+
* Read + parse + guard `data/dashboard-layouts.json` for a layout-seeding
189+
* migration. Collapses the preamble every such migration repeats: resolve the
190+
* path, read the file (absent → fresh install, nothing to do), JSON-parse it
191+
* (unreadable → skip), and verify `doc.layouts` is an array.
192+
*
193+
* Returns a discriminated result:
194+
* - `{ ok: false, reason: 'no-state' | 'unreadable' | 'no-layouts-array', path }`
195+
* — the caller short-circuits with `return { updated: 0, reason: result.reason }`.
196+
* - `{ ok: true, doc, path }` — mutate `doc.layouts` in place, then persist
197+
* with `writeLayoutsDoc(path, doc)`.
198+
*
199+
* `label` is the migration's human tag (e.g. `'migration 029'`); it keeps the
200+
* no-state / unreadable log lines per-migration identifiable.
201+
*/
202+
export async function readLayoutsDoc({ rootDir, label }) {
203+
const path = join(rootDir, 'data', 'dashboard-layouts.json');
204+
const raw = await readFile(path, 'utf-8').catch((err) => {
205+
if (err.code === 'ENOENT') return null;
206+
throw err;
207+
});
208+
if (raw == null) {
209+
console.log(`📦 ${label}: no dashboard-layouts.json yet — fresh install will seed from defaults.`);
210+
return { ok: false, reason: 'no-state', path };
211+
}
212+
let doc;
213+
try {
214+
doc = JSON.parse(raw);
215+
} catch {
216+
console.log(`📦 ${label}: dashboard-layouts.json unreadable — skipping.`);
217+
return { ok: false, reason: 'unreadable', path };
218+
}
219+
if (!doc || !Array.isArray(doc.layouts)) {
220+
return { ok: false, reason: 'no-layouts-array', path };
221+
}
222+
return { ok: true, doc, path };
223+
}
224+
225+
/** Persist a layouts doc with the canonical 2-space indentation. */
226+
export async function writeLayoutsDoc(path, doc) {
227+
await writeFile(path, JSON.stringify(doc, null, 2));
228+
}

scripts/migrations/_lib.test.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync
1313
import { tmpdir } from 'os';
1414
import { join } from 'path';
1515

16-
import { applyPromptReplaceMigration, md5 } from './_lib.js';
16+
import { applyPromptReplaceMigration, md5, readLayoutsDoc, writeLayoutsDoc } from './_lib.js';
1717

1818
const FILENAME = 'pipeline-fake.md';
1919
const BODY_OLD = '# OLD\n';
@@ -108,3 +108,62 @@ describe('applyPromptReplaceMigration opt-ins', () => {
108108
});
109109
});
110110
});
111+
112+
describe('readLayoutsDoc / writeLayoutsDoc', () => {
113+
let rootDir;
114+
let dataDir;
115+
let layoutsPath;
116+
117+
beforeEach(() => {
118+
rootDir = mkdtempSync(join(tmpdir(), 'migration-layouts-'));
119+
dataDir = join(rootDir, 'data');
120+
mkdirSync(dataDir, { recursive: true });
121+
layoutsPath = join(dataDir, 'dashboard-layouts.json');
122+
});
123+
124+
afterEach(() => {
125+
rmSync(rootDir, { recursive: true, force: true });
126+
});
127+
128+
it('reports no-state when the file is absent', async () => {
129+
const result = await readLayoutsDoc({ rootDir, label: 'migration test' });
130+
expect(result.ok).toBe(false);
131+
expect(result.reason).toBe('no-state');
132+
expect(result.path).toBe(layoutsPath);
133+
});
134+
135+
it('reports unreadable for malformed JSON', async () => {
136+
writeFileSync(layoutsPath, 'not json');
137+
const result = await readLayoutsDoc({ rootDir, label: 'migration test' });
138+
expect(result.ok).toBe(false);
139+
expect(result.reason).toBe('unreadable');
140+
});
141+
142+
it('reports no-layouts-array when the layouts key is missing or non-array', async () => {
143+
writeFileSync(layoutsPath, JSON.stringify({ activeLayoutId: 'default' }));
144+
expect((await readLayoutsDoc({ rootDir, label: 'x' })).reason).toBe('no-layouts-array');
145+
writeFileSync(layoutsPath, JSON.stringify({ layouts: 'nope' }));
146+
expect((await readLayoutsDoc({ rootDir, label: 'x' })).reason).toBe('no-layouts-array');
147+
writeFileSync(layoutsPath, 'null');
148+
expect((await readLayoutsDoc({ rootDir, label: 'x' })).reason).toBe('no-layouts-array');
149+
});
150+
151+
it('returns the parsed doc + path when valid', async () => {
152+
const doc = { activeLayoutId: 'default', layouts: [{ id: 'default', widgets: [] }] };
153+
writeFileSync(layoutsPath, JSON.stringify(doc));
154+
const result = await readLayoutsDoc({ rootDir, label: 'migration test' });
155+
expect(result.ok).toBe(true);
156+
expect(result.path).toBe(layoutsPath);
157+
expect(result.doc).toEqual(doc);
158+
});
159+
160+
it('round-trips through writeLayoutsDoc with 2-space indentation', async () => {
161+
const doc = { activeLayoutId: 'default', layouts: [{ id: 'default', widgets: ['cos'] }] };
162+
await writeLayoutsDoc(layoutsPath, doc);
163+
const raw = readFileSync(layoutsPath, 'utf-8');
164+
expect(raw).toBe(JSON.stringify(doc, null, 2));
165+
const reread = await readLayoutsDoc({ rootDir, label: 'migration test' });
166+
expect(reread.ok).toBe(true);
167+
expect(reread.doc).toEqual(doc);
168+
});
169+
});

0 commit comments

Comments
 (0)