Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(storage): make the project catalog part of the operational database by Astro-Han · Pull Request #2016 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/main/app-lifecycle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type {
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
Expand DownExpand Up@@ -183,6 +184,23 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
}

async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}

async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
if ((await connectionStore.list()).length > 0) return;
Expand DownExpand Up@@ -302,6 +320,9 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void {
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/boot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,10 @@ async function confirmDesktopStorageRootRepair(): Promise<boolean> {
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { mkdir, rm } from 'node:fs/promises';
import type { UiLocale, E2eFixtureScenario, E2eFixtureState } from '@maka/core';
import {
backfillSessionProjects,
createProjectCatalog,
createSessionStore,
} from '@maka/storage';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import type { CredentialStore } from './credential-store.js';
import {
Expand DownExpand Up@@ -761,4 +766,23 @@ export async function seedE2eFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
await seedSessionProjects(input.workspaceRoot);
}

/**
* Resolve every seeded session's project here rather than leaving it to the
* startup backfill. The fixture is meant to hand the renderer a settled state:
* the app resolves projects in background startup, concurrently with window
* creation, so a test that asserts on project grouping would otherwise be
* racing the resolver instead of exercising the sidebar.
*/
async function seedSessionProjects(workspaceRoot: string): Promise<void> {
const sessions = createSessionStore(workspaceRoot);
const catalog = createProjectCatalog(workspaceRoot);
try {
await backfillSessionProjects({ sessions, catalog });
} finally {
await sessions.close?.();
catalog.close();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,12 +136,14 @@ describe('SQLite agent graph intent claims', () => {

const legacy = new DatabaseSync(path);
legacy.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
DROP TABLE agent_graph_supervisor_wake_attempts;
DROP TABLE agent_graph_supervisor_wakes;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -223,12 +223,14 @@ describe('SQLite Agent Graph supervisor wakes', () => {

const v11 = new DatabaseSync(path);
v11.exec(`
DROP TABLE session_messages;
DROP INDEX session_metadata_tombstones_by_retirement_unit;
ALTER TABLE session_metadata_tombstones DROP COLUMN cleanup_pending;
ALTER TABLE session_metadata_tombstones DROP COLUMN retirement_unit_id;
DROP TABLE session_create_claims;
DROP TABLE sandbox_boundary_log;
DROP TABLE project_aliases;
DROP TABLE project_locations;
DROP TABLE projects;
DROP TABLE session_messages;
`);
v11
Expand Down
32 changes: 31 additions & 1 deletion packages/storage/src/__tests__/operational-state-backup.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createSqliteArtifactStore } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
createOperationalStateBackup,
Expand All@@ -17,9 +18,21 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
const projectPath = join(base, 'project');
await mkdir(projectPath);
const sessions = createSessionStore(stateRoot);
try {
// The project catalog decides how every session is grouped, and its name,
// relink aliases and archive state exist nowhere else. Restoring sessions
// without it would silently reorganize the user's whole sidebar.
const catalog = createProjectCatalog(stateRoot, { now: () => 5 });
const project = await catalog.register(projectPath);
await catalog.rename(project.id, 'Renamed Project');
await catalog.archive(project.id);
catalog.close();

const session = await sessions.create({
projectId: project.id,
cwd: '/tmp/cwd',
backend: 'fake',
llmConnectionSlug: 'fake',
Expand DownExpand Up@@ -54,14 +67,31 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });

const restored = createSessionStore(restoreRoot);
const restoredCatalog = createProjectCatalog(restoreRoot);
try {
assert.equal((await restored.readMessages(session.id))[0]?.id, 'message-1');
assert.equal(
await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'),
'artifact',
);
assert.equal(
(await restored.readHeaderSnapshot(session.id)).projectId,
project.id,
'a restored session still belongs to the project it was grouped under',
);
assert.deepEqual(await restoredCatalog.list(), [
{
id: project.id,
name: 'Renamed Project',
locations: [{ path: await realpath(projectPath), isWorktree: false }],
archivedAt: 5,
available: true,
preferredPath: await realpath(projectPath),
},
]);
} finally {
await restored.close?.();
restoredCatalog.close();
}
} finally {
await rm(base, { recursive: true, force: true });
Expand Down
157 changes: 153 additions & 4 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,9 @@ test('a Git probe failure cannot persistently downgrade a repository to a folder
await execFileAsync('git', ['init', '--quiet'], { cwd: repository });

await assert.rejects(() => registerProjectWithoutGit(repository, storage));
await assert.rejects(() => readFile(join(storage, 'projects.json')), { code: 'ENOENT' });
// Nothing may be recorded: a folder identity written here would outlive the
// probe failure and permanently split the repository from its worktrees.
assert.deepEqual(await createProjectCatalog(storage).list(), []);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand DownExpand Up@@ -227,6 +229,84 @@ test('a missing project directory remains in the catalog as unavailable', async
}
});

test('two catalogs changing one project at the same time keep both changes', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-'));
try {
const workspace = join(base, 'workspace');
const storage = join(base, 'storage');
await mkdir(workspace);
const first = createProjectCatalog(storage, { now: () => 1_000 });
const second = createProjectCatalog(storage, { now: () => 2_000 });
const project = await first.register(workspace);
// Both catalogs settle their one-time legacy-import probe first, so the two
// mutations below really do overlap instead of queueing behind that I/O.
await Promise.all([first.list(), second.list()]);

// Each catalog rewrites the whole table; without holding the write lock
// across its own read, the later writer replays a stale copy and the other
// window's edit disappears with no error anywhere.
await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]);

const [merged] = await first.list();
assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive');
assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename');
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a relink whose merge target changes mid-flight fails instead of half-committing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-race-'));
try {
const storage = join(base, 'storage');
const home = join(base, 'home');
const shared = join(base, 'shared');
const elsewhere = join(base, 'elsewhere');
await Promise.all([mkdir(home), mkdir(shared), mkdir(elsewhere)]);
const first = createProjectCatalog(storage);
const second = createProjectCatalog(storage);
const moving = await first.register(home);
const target = await first.register(shared);
await Promise.all([first.list(), second.list()]);

let releaseCallback!: () => void;
let callbackStarted!: () => void;
const gate = new Promise<void>((release) => {
releaseCallback = release;
});
const started = new Promise<void>((resolve) => {
callbackStarted = resolve;
});
let observed: string | undefined;
const relink = first.relink(moving.id, shared, async (context) => {
observed = context.conflictingProjectId;
callbackStarted();
await gate;
});
await started;

// The callback was told to move `target`'s sessions onto `moving`. While it
// is doing that, the other window moves `target` somewhere else entirely.
await second.relink(target.id, elsewhere);
releaseCallback();

assert.equal(observed, target.id, 'precondition: the callback planned a merge');
await assert.rejects(() => relink, /retry/);
const projects = await first.list();
assert.deepEqual(
projects.map((project) => project.id).sort(),
[moving.id, target.id].sort(),
'neither project may be merged away after the plan went stale',
);
first.close();
second.close();
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relinking an unavailable project preserves its id and adopts the new directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-'));
try {
Expand DownExpand Up@@ -418,7 +498,7 @@ test('selecting a project returns its most recent available location and rejects
}
});

test('a malformed project catalog fails closed without overwriting it', async () => {
test('a malformed legacy catalog is reported and preserved without blocking the catalog', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-corrupt-'));
try {
const workspace = join(base, 'workspace');
Expand All@@ -428,10 +508,79 @@ test('a malformed project catalog fails closed without overwriting it', async ()
await mkdir(storage);
const original = '{"schemaVersion":1,"projects":[{}]}\n';
await writeFile(catalogPath, original, 'utf8');
const catalog = createProjectCatalog(storage);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
onLegacyImportFailure: (error) => failures.push(error),
});

await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/);
// SQLite is the authority: a legacy file that cannot be read must not take
// the catalog down with it, and it must stay on disk to recover by hand.
const project = await catalog.register(workspace);

assert.equal((await catalog.list()).length, 1);
assert.equal((await catalog.list())[0]?.id, project.id);
assert.equal(await readFile(catalogPath, 'utf8'), original);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /Invalid project catalog/);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('a legacy catalog is imported once and then set aside', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-import-'));
try {
const storage = join(base, 'storage');
await mkdir(storage);
await writeFile(
join(storage, 'projects.json'),
JSON.stringify({
schemaVersion: 1,
projects: [
{
id: 'legacy-project',
aliases: ['merged-away'],
name: 'Renamed By Hand',
identity: 'folder:/gone',
locations: [{ path: '/gone', isWorktree: false, lastUsedAt: 5 }],
lastUsedAt: 7,
archivedAt: 9,
},
],
}),
'utf8',
);
const failures: unknown[] = [];
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
onLegacyImportFailure: (error) => failures.push(error),
});

const projects = await catalog.list();

assert.deepEqual(failures, []);
// The user's name, relink aliases and archive state only ever lived in this
// file; losing them on upgrade would be indistinguishable from data loss.
assert.equal(projects.length, 1);
assert.equal(projects[0]?.id, 'legacy-project');
assert.equal(projects[0]?.name, 'Renamed By Hand');
assert.deepEqual(projects[0]?.aliases, ['merged-away']);
assert.equal(projects[0]?.archivedAt, 9);
await assert.rejects(() => readFile(join(storage, 'projects.json'), 'utf8'), {
code: 'ENOENT',
});
const setAside = JSON.parse(
await readFile(join(storage, 'projects.json.imported-1000'), 'utf8'),
) as { projects: Array<{ id: string }> };
assert.deepEqual(
setAside.projects.map((project) => project.id),
['legacy-project'],
'the imported file is kept verbatim so a bad upgrade stays recoverable',
);

// A catalog opened later must not re-import and must not lose the state.
catalog.close();
assert.equal((await createProjectCatalog(storage).list()).length, 1);
} finally {
await rm(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading