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
680 changes: 341 additions & 339 deletions docs/testing/architecture-invariants.json

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions docs/testing/main-capability-coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": 1,
"audit": {
"base": "2b34c653119bdf480f2af0330ee3809b51441807",
"head": "a10518304946d75f9a2e55b86f38f056cb9c6f56",
"head": "d96a51d839cfdd9ac36b6827f8816ef39016cfaf",
"ignoredDocumentationCommits": [
"dd86a325e3db5aa013ffa18a647e67e9fa279c10",
"0b0e12b4d97ec7d77a8a93076459fdaad2e52070",
Expand Down Expand Up @@ -481,7 +481,8 @@
"2cc5b47de6e8fe51b4998293b0dc224ea908fdf3",
"37027f5c598f916c0656343248fb059be7ab8daf",
"6af42109faaf686b8d6d8a1f107b243bb22a745a",
"cd0dda6cf6fbd015822f343eec5f7faa7e4b52c8"
"cd0dda6cf6fbd015822f343eec5f7faa7e4b52c8",
"9d8b0e6ab57351b0f48848849be5f518ec8917e9"
]
},
"capabilities": [
Expand Down Expand Up @@ -2302,7 +2303,8 @@
"e5b496b8f2a345cf704643d473139a2ba87a3e76",
"2d133a0a0cb3883d94e5ea2590b1ad9e39b8f927",
"c27b66f8efb2dff402e5d1be2969b410fd12023c",
"32e4d5c3058a0d12a7cb03d9b5b1501d3ead3a6e"
"32e4d5c3058a0d12a7cb03d9b5b1501d3ead3a6e",
"d96a51d839cfdd9ac36b6827f8816ef39016cfaf"
],
"behaviors": [
"WORKTREE-GROUPS-BASELINE-001",
Expand Down
51 changes: 50 additions & 1 deletion scripts/architecture/checkSingleWriters.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,20 @@
* Bypass check: every literal in stateFamily.persistenceKeys may appear only
* inside the store file — a memento-key reference is a raw write path around
* the authority.
*
* Harness Simplification PR 5/6: a family whose stateFamily declares
* `writerFacade: true` no longer needs the type-resolved method scan. Its
* store exposes no write capability through the module entrypoint (a
* capability-free handle plus narrow read/write views), so enforcement is
* structural: only declared writers and the module entrypoint may import the
* store file, checked on the dependency graph instead of the AST.
*/

const fs = require('fs');
const path = require('path');
const ts = require('typescript');
const { loadArchitecturePolicy } = require('./loadArchitecturePolicy');
const { buildDependencyGraph } = require('./buildDependencyGraph');

const INVARIANTS_PATH = path.join('docs', 'testing', 'architecture-invariants.json');
const INVARIANT_ID_PATTERN = /^ARCH-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}$/;
Expand Down Expand Up @@ -200,6 +208,10 @@ function validateCatalog(rootDirectory, policy) {
if (!Array.isArray(invariant.writers) || invariant.writers.length === 0) {
errors.push(`${owner}: single-writer enforcement requires a non-empty writers set`);
}
if (family && family.writerFacade !== undefined
&& typeof family.writerFacade !== 'boolean') {
errors.push(`${owner}: stateFamily.writerFacade must be a boolean when present`);
}
}
}
return { catalog, errors };
Expand Down Expand Up @@ -329,8 +341,45 @@ function checkWriters(rootDirectory, catalog, policy) {
writeMethods: new Set(family.writeMethods),
writers: new Set([...invariant.writers, family.storePath]),
persistenceKeys: family.persistenceKeys || [],
writerFacade: family.writerFacade === true,
});
}
// Harness Simplification PR 5/6: a family declaring `writerFacade`
// exposes no write capability through the module entrypoint (handle +
// narrow views), so the type-resolved method scan is replaced by a
// structural import rule: only declared writers (and the module
// entrypoint wiring the facade) may import the store file at all.
const facadeStores = new Map();
for (const [storePath, families] of familiesByStore) {
const withFacade = families.filter(family => family.writerFacade);
if (withFacade.length > 0 && withFacade.length !== families.length) {
errors.push(`single-writer: families sharing store ${storePath} disagree on `
+ 'writerFacade — the facade is a store-level property');
continue;
}
if (withFacade.length > 0) { facadeStores.set(storePath, families); }
}
if (facadeStores.size > 0) {
const { edges, errors: graphErrors } = buildDependencyGraph(rootDirectory);
errors.push(...graphErrors);
const entrypointsByModule = new Map(policy.modules.map(module =>
[module.id, new Set(module.publicEntrypoints || [])]));
for (const [storePath, families] of facadeStores) {
const unionWriters = new Set(families.flatMap(family => [...family.writers]));
const storeModule = policy.classification.get(storePath)?.moduleId;
const allowed = new Set([
...unionWriters,
...(entrypointsByModule.get(storeModule) || new Set()),
storePath,
]);
for (const edge of edges) {
if (edge.target !== storePath || allowed.has(edge.source)) { continue; }
errors.push(`single-writer: ${edge.source} imports facade store ${storePath} — `
+ 'only declared writers and the module entrypoint may import it; write '
+ 'capability is no longer reachable through the entrypoint');
}
}
}
let typeContext = null;
const typeContextLazy = () => {
if (!typeContext) { typeContext = buildTypeContext(rootDirectory, policy.files); }
Expand All @@ -350,7 +399,7 @@ function checkWriters(rootDirectory, catalog, policy) {
}
}
}
if (unionWriters.has(file)) { continue; }
if (unionWriters.has(file) || facadeStores.has(storePath)) { continue; }
for (const violation of findTypeResolvedViolations(
rootDirectory, typeContextLazy(), file, families, unionWriters)) {
errors.push(`single-writer: ${file} ${violation} — route the write through `
Expand Down
60 changes: 32 additions & 28 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ import type { WorktreeKey } from './worktrees';
import { WorktreeBaseRefStore } from './worktrees';
import {
WorktreeGroupManifestError,
WorktreeGroupManifestStore,
createWorktreeGroupManifestStore,
worktreeGroupManifestReaderOf,
worktreeGroupManifestWriterOf,
} from './worktrees';
import { WorktreeDeletionController } from './worktrees';
import { reconcileWorktreeGroupManifest } from './worktrees';
Expand Down Expand Up @@ -1308,7 +1310,9 @@ async function initializeDashboard(
getAgentPivotConfiguration().get<unknown>('worktreeDirectory', '.worktrees'))
);
const worktreeSetupRunner = new WorktreeSetupRunner();
const worktreeGroupManifestStore = new WorktreeGroupManifestStore(context.globalState);
const worktreeGroupManifestStore = createWorktreeGroupManifestStore(context.globalState);
const worktreeGroupManifestReader = worktreeGroupManifestReaderOf(worktreeGroupManifestStore);
const worktreeGroupManifestWriter = worktreeGroupManifestWriterOf(worktreeGroupManifestStore);
const worktreeMemberLifecycle = new WorktreeMemberLifecycle(worktreeGroupManifestStore);
const gitWorktreeDiscovery = new GitWorktreeDiscovery({
getBaseRef: repositoryKey => worktreeBaseRefStore.get(repositoryKey),
Expand Down Expand Up @@ -1436,7 +1440,7 @@ async function initializeDashboard(
// explicit retired-record cleanup.
const reconcilePendingGenerationClaims = async (workspace: OpenWorkspace) => {
const identity = workspace.navigationIdentity;
const pendingClaims = worktreeGroupManifestStore.listGenerationClaims(identity)
const pendingClaims = worktreeGroupManifestReader.listGenerationClaims(identity)
.filter(claim => claim.state === 'pending');
if (!pendingClaims.length) {
return;
Expand Down Expand Up @@ -1480,7 +1484,7 @@ async function initializeDashboard(
logError('Ambiguous terminal bindings skipped during claim reconciliation.', null);
return;
}
await worktreeGroupManifestStore.reconcileGenerationClaims(identity, claim =>
await worktreeGroupManifestWriter.reconcileGenerationClaims(identity, claim =>
resolveGenerationClaimDisposition(claim, {
navigationIdentity: identity,
boundSessionByMarkerPath: boundByMarkerPath,
Expand All @@ -1503,7 +1507,7 @@ async function initializeDashboard(
// creation time; sessions without a retired path have no
// claim and the missing-claim rejection is expected.
try {
await worktreeGroupManifestStore.promoteGenerationClaim(
await worktreeGroupManifestWriter.promoteGenerationClaim(
navigationIdentity, pendingId, { provider, sessionId });
} catch (error) {
if ((error as { code?: string })?.code !== 'invalid-record') {
Expand Down Expand Up @@ -1555,15 +1559,15 @@ async function initializeDashboard(
getProvisioningWorktrees: navigationIdentity =>
isolatedSessionController?.getVisibleRows(navigationIdentity) || [],
getWorktreeGroups: navigationIdentity =>
worktreeGroupManifestStore.listGroups(navigationIdentity),
worktreeGroupManifestReader.listGroups(navigationIdentity),
getDeletionJournals: navigationIdentity =>
worktreeGroupManifestStore.listDeletionJournals(navigationIdentity),
worktreeGroupManifestReader.listDeletionJournals(navigationIdentity),
getRetiredWorktreeIdentities: navigationIdentity =>
worktreeGroupManifestStore.listRetiredIdentities(navigationIdentity),
worktreeGroupManifestReader.listRetiredIdentities(navigationIdentity),
getGenerationClaims: navigationIdentity =>
worktreeGroupManifestStore.listGenerationClaims(navigationIdentity),
worktreeGroupManifestReader.listGenerationClaims(navigationIdentity),
isRetiredStoreCorrupt: navigationIdentity =>
worktreeGroupManifestStore.isRetiredStoreCorrupt(navigationIdentity),
worktreeGroupManifestReader.isRetiredStoreCorrupt(navigationIdentity),
onDidReadSessions: (workspace, sessionResults, reason) => {
void workspacePendingSessionPromotionController.promote(
workspace,
Expand All @@ -1588,7 +1592,7 @@ async function initializeDashboard(
getCurrentOpenWorkspace,
getWorktreeSnapshot: () => worktreeSnapshotCoordinator.getSnapshot(),
getWorktreeGroupPeerKeys: (navigationIdentity, key) => {
const group = worktreeGroupManifestStore.findGroupByWorktreeKey(
const group = worktreeGroupManifestReader.findGroupByWorktreeKey(
navigationIdentity, key);
if (!group) {
return null;
Expand All @@ -1604,39 +1608,39 @@ async function initializeDashboard(
.map(member => ({ ...member.worktreeKey! }));
},
isWorktreeGroupProvisioning: (navigationIdentity, key) => {
const group = worktreeGroupManifestStore.findGroupByWorktreeKey(
const group = worktreeGroupManifestReader.findGroupByWorktreeKey(
navigationIdentity, key);
return !!group && group.members.some(member =>
member.state === 'planned' || member.state === 'provisioning');
},
getRetiredWorktreeIdentities: navigationIdentity =>
worktreeGroupManifestStore.listRetiredIdentities(navigationIdentity),
worktreeGroupManifestReader.listRetiredIdentities(navigationIdentity),
isWorktreeRetiredStoreCorrupt: navigationIdentity =>
worktreeGroupManifestStore.isRetiredStoreCorrupt(navigationIdentity),
worktreeGroupManifestReader.isRetiredStoreCorrupt(navigationIdentity),
// Every worktree session creation — retired path or not — runs its
// whole admission phase (lease check, claim persistence, runtime
// creation) under the shared per-group deletion admission mutex
// (PRD §6.4, decision J). The claim write itself stays a plain
// store call: it always happens inside the admission wrapper.
createWorktreeGenerationClaim: (navigationIdentity, input) =>
worktreeGroupManifestStore.createGenerationClaim(navigationIdentity, input),
worktreeGroupManifestWriter.createGenerationClaim(navigationIdentity, input),
withWorktreeDeletionAdmission: (scope, operation) => {
const group = worktreeGroupManifestStore.findGroupByWorktreeKey(
const group = worktreeGroupManifestReader.findGroupByWorktreeKey(
scope.workspaceNavigationIdentity, scope.worktreeKey);
if (!group) {
return operation();
}
return worktreeDeletionController.withAdmissionLock(
scope.workspaceNavigationIdentity, group.groupId, async () => {
if (worktreeGroupManifestStore.isGroupDeletionLeased(
if (worktreeGroupManifestReader.isGroupDeletionLeased(
scope.workspaceNavigationIdentity, group.groupId)) {
throw new WorktreeGroupManifestError('group-leased');
}
return operation();
});
},
removeWorktreeGenerationClaim: (navigationIdentity, claimId) =>
worktreeGroupManifestStore.removeGenerationClaim(navigationIdentity, claimId),
worktreeGroupManifestWriter.removeGenerationClaim(navigationIdentity, claimId),
getRegisteredAiSessionProvider,
getRegisteredAiSessionProviders,
getAiSessionRuntimeById,
Expand Down Expand Up @@ -1759,10 +1763,10 @@ async function initializeDashboard(
}
return;
}
if (worktreeGroupManifestStore.findGroupByWorktreeKey(bucket, info.worktreeKey)) {
if (worktreeGroupManifestReader.findGroupByWorktreeKey(bucket, info.worktreeKey)) {
return;
}
await worktreeGroupManifestStore.createGroup(bucket, {
await worktreeGroupManifestWriter.createGroup(bucket, {
displayName: info.plan.taskName,
suggestedSlug: info.plan.slug,
members: [{
Expand Down Expand Up @@ -1793,7 +1797,7 @@ async function initializeDashboard(
const currentWorktreeGroupsAggregateRevision = () => {
const identity = getCurrentOpenWorkspace()?.navigationIdentity;
return identity
? worktreeGroupManifestStore.getAggregateRevision(identity)
? worktreeGroupManifestReader.getAggregateRevision(identity)
: null;
};
let currentAiSessionRefreshReason = 'refresh';
Expand Down Expand Up @@ -2029,7 +2033,7 @@ async function initializeDashboard(
getCards: projection => getOpenWorkspaceCards(projection),
getWorktreeGroupsAggregateRevision: navigationIdentity =>
navigationIdentity
? worktreeGroupManifestStore.getAggregateRevision(navigationIdentity)
? worktreeGroupManifestReader.getAggregateRevision(navigationIdentity)
: null,
getRunningCardAnimation: () => getEffectiveRunningCardAnimation(getAgentPivotConfiguration()),
getRunningIconAnimation: () => getEffectiveRunningIconAnimation(getAgentPivotConfiguration()),
Expand Down Expand Up @@ -2076,7 +2080,7 @@ async function initializeDashboard(
// leaving a ghost group behind. The bucket identity was captured
// when the removal started, so a workspace switch cannot divert it.
if (workspaceIdentity) {
const group = worktreeGroupManifestStore.findGroupByWorktreeKey(
const group = worktreeGroupManifestReader.findGroupByWorktreeKey(
workspaceIdentity, removedKey);
const member = group?.members.find(candidate => candidate.worktreeKey
&& worktreeKeysEqual(candidate.worktreeKey, removedKey));
Expand Down Expand Up @@ -2301,10 +2305,10 @@ async function initializeDashboard(
return undefined;
},
findGroupByWorktreeKey: (navigationIdentity, key) =>
worktreeGroupManifestStore.findGroupByWorktreeKey(
worktreeGroupManifestReader.findGroupByWorktreeKey(
navigationIdentity, key),
listRetiredIdentities: navigationIdentity =>
worktreeGroupManifestStore.listRetiredIdentities(navigationIdentity),
worktreeGroupManifestReader.listRetiredIdentities(navigationIdentity),
openWorkingChangeDiff: (worktreePath, item) =>
openWorkingChangeDiff(worktreePath, item),
openTaskResultReview: (worktreePath, baselineSha, title) =>
Expand Down Expand Up @@ -2501,7 +2505,7 @@ async function initializeDashboard(
store: worktreeGroupManifestStore,
controller: worktreeDeletionController,
probeMemberBlocker: async (navigationIdentity, groupId, memberId) => {
const member = worktreeGroupManifestStore.listGroups(navigationIdentity)
const member = worktreeGroupManifestReader.listGroups(navigationIdentity)
.find(candidate => candidate.groupId === groupId)
?.members.find(candidate => candidate.memberId === memberId);
if (!member) {
Expand All @@ -2512,7 +2516,7 @@ async function initializeDashboard(
: 'worktree-not-removable';
},
countMemberHistorySessions: async (navigationIdentity, groupId, memberId) => {
const member = worktreeGroupManifestStore.listGroups(navigationIdentity)
const member = worktreeGroupManifestReader.listGroups(navigationIdentity)
.find(candidate => candidate.groupId === groupId)
?.members.find(candidate => candidate.memberId === memberId);
if (!member) {
Expand Down Expand Up @@ -2901,7 +2905,7 @@ async function initializeDashboard(
// unmark the identity so the NEXT certain snapshot
// retries instead of leaving the group leased for
// the rest of this activation.
const stillPending = worktreeGroupManifestStore
const stillPending = worktreeGroupManifestReader
.listDeletionJournals(identity)
.some(entry => entry.targets.some(target =>
target.status === 'pending'));
Expand Down
Loading
Loading