diff --git a/LESSONS.md b/LESSONS.md index 77648d9e..6301e79a 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -767,3 +767,7 @@ Seen 1× (native projection regressions). A correct native stop/binding is insuf ### 2026-11-03 — Read-model lookups must not materialize domain facts Seen 1× (actual SQLite API regression). A Task query requested full delivery definitions, creating an unsaved default role catalog. A later Task command persisted it and emitted an unrelated Project change. Return deterministic legacy defaults without mutation and use committed raw catalogs for metadata; keep materialization in explicit writes. + +### 2026-11-03 — Absence of a new contract is not legacy admission + +Seen 1× (group dispatch and Bot tracking reproduced the bypass). Capture existing Project identities once with committed evidence; later untyped rows must not inherit admission. Preserve original rework and explicit Workflow execution through trusted continuation/controller ports. Scope guards must run before mutating leases or claims, and physical storage rollback must retain the code-version fence. diff --git a/PROJECT.md b/PROJECT.md index 52340738..5f086a1c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -284,3 +284,7 @@ Composition checkpoint: current role governance/native exit, bounded context and SQLite composition now has an actual DSH native return probe: canonical Chief intake, persisted activation/exit, original role context and single-resource handoff are read from committed SQL snapshots. Inference remains deterministic; no production migration or full-provider workflow acceptance is claimed. Service composition adds scoped native execution/exit and current-vs-pinned role metadata, role governance aliases, and bounded per-entity committed snapshots. New projections and final six-case DSH/SQLite validation are under current review; none of this branch is deployed. + +### 2026-11-03 — Preserve original capabilities (#2186) + +Keep personal Chief/Bots, groups, private memories and original Task/Workflow IDs. Shared Project legacy execution requires an identity-bound committed cutover; new dispatch/tracking cannot bypass Chief admission. Associated original groups retain their own history ACL plus current Project access. JSON version4 fences new contracts across SQLite rollback; code downgrades must not erase receipts. No deployment or UI replacement. See docs/subsystems/project-compatibility.md. diff --git a/ROADMAP.md b/ROADMAP.md index fde2a34b..4017fb43 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -455,3 +455,5 @@ New shared Tasks have Chief admission and versioned requirements. Existing Task/ Native prior-host reconciliation now reuses verified supervisor process-group facts; native v2 registered-work/lineage and shared adapter-exit regressions guard against premature initiative transfer. In-flight current-rights abort and composed-stack acceptance remain active. Canonical ongoing role/resource authority checks now reuse native step/SDK input and the existing scheduler + pool stop. Frozen role context remains stable; current revoked rights stop execution, with unknown exit held. Compose descendants and verify parent Task resume/API projections next. + +#2186 adds the original-capability inventory and committed legacy cutover on the existing Store, group/Chief channels and Workflow controller. Validate same-version JSON/SQLite rollback separately from real-provider and UI acceptance; final real backend packaging/README #2189 follows. diff --git a/changelog.d/2186.fixed.md b/changelog.d/2186.fixed.md new file mode 100644 index 00000000..682f2f64 --- /dev/null +++ b/changelog.d/2186.fixed.md @@ -0,0 +1 @@ +Preserve original personal Chief, groups and Task/Workflow histories while fencing new Project work through Chief admission and durable Handoff. Retain private group permissions, committed legacy continuations and the new-state format guard across JSON/SQLite migration and rollback, with a scoped compatibility diagnostic. diff --git a/core/index.js b/core/index.js index 6b1fd0df..38ed1feb 100644 --- a/core/index.js +++ b/core/index.js @@ -5,6 +5,7 @@ import { join } from 'node:path' import * as diagnostics from './src/host/diagnostics.js' import * as store from './src/host/store.js' import * as projectEffects from './src/host/project-effects.js' +import * as projectCompatibility from './src/host/project-compatibility.js' import * as projectInputs from './src/host/project-inputs.js' import * as projectSources from './src/host/project-sources.js' import * as projectExecutionBoundary from './src/host/project-execution-boundary.js' @@ -83,7 +84,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, projectEffects, projectTaskLifecycle, projectInputs, harness, memberAuth, platformIdentity, projectAccess, projectDevelopment, projectExecutionBoundary, projectResources, projectHarness, projectRuntimeSetup, projectDelivery, projectDelegations, projectAgents, projectCredentials, projectGithubRead, projectPools, projectScheduledTasks, projectSources, projectChiefControl, projectScheduler, workbenchState, authz, personalAgentAuth, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] +const MODULES = [diagnostics, store, projectEffects, projectCompatibility, projectTaskLifecycle, projectInputs, harness, memberAuth, platformIdentity, projectAccess, projectDevelopment, projectExecutionBoundary, projectResources, projectHarness, projectRuntimeSetup, projectDelivery, projectDelegations, projectAgents, projectCredentials, projectGithubRead, projectPools, projectScheduledTasks, projectSources, projectChiefControl, projectScheduler, workbenchState, authz, personalAgentAuth, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/host/project-access.js b/core/src/host/project-access.js index 3cfc9aa4..95e3a902 100644 --- a/core/src/host/project-access.js +++ b/core/src/host/project-access.js @@ -59,7 +59,7 @@ export function install(rt) { queueDepth: queue.length, messages: [], preview: null } }) } } - async function workflow(body, actor) { + async function workflow(body, actor, controllerAttempt) { const id = String(body.projectId ?? '') const operation = body.operation ?? 'state' const actions = { state: 'read', templates: 'read', create: 'configure', graph: 'configure', concurrency: 'configure', @@ -81,6 +81,9 @@ export function install(rt) { if (!task || task.projectId !== id) throw refusal('A Project Run needs a Task from this Project') rt.requireProjectAccess(actor, id, 'task-write', task) rt.projectTaskLifecycle?.requireExecutable(task) + if (task.projectTask && (!controllerAttempt || ownEntry(state.projectAttempts ?? {}, controllerAttempt.id) !== controllerAttempt + || controllerAttempt.taskId !== task.id || controllerAttempt.projectId !== id || controllerAttempt.workflowId !== item.id + || task.deliveryAttemptId !== controllerAttempt.id || controllerAttempt.status !== 'running')) throw refusal('Use task.run so the Project Task controller owns execution and Handoff') payload = { workflowId: item.id, taskId: task.id, title: task.title, brief: task.note ?? task.title, projectId: id, callerBotId: project.chief?.botId } const existing = activeRuns(id).find(run => run.taskId === task.id) if (existing) { @@ -110,6 +113,8 @@ export function install(rt) { workflowTail = result.then(() => {}, () => {}) return result } + rt.assignProjectDeliveryWorkflow = (attempt, actor) => workflow({ projectId: attempt.projectId, operation: 'assign', workflowId: attempt.workflowId, taskId: attempt.taskId }, actor, attempt) + rt.projectAccessEndpoint = async (endpoint, body = {}, actor) => { if (!rt.sharedProjectMode()) return undefined if (!actor?.memberId || !ownEntry(state.members, actor.memberId)) throw refusal('Sign in first') diff --git a/core/src/host/project-compatibility.js b/core/src/host/project-compatibility.js new file mode 100644 index 00000000..3c3ca20a --- /dev/null +++ b/core/src/host/project-compatibility.js @@ -0,0 +1,65 @@ +// Keep original records authoritative; only pre-cutover Project work may use legacy drivers. +import { createHash, randomUUID } from 'node:crypto' +import { ownEntry } from './store.js' + +const VERSION = 'project-compatibility/v1' +const fail = message => Object.assign(new Error(message), { code: 'bot/compatibility' }) +const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const identity = task => ({ taskId: task.id, projectId: task.projectId, requesterId: task.requesterId ?? null, createdAt: task.createdAt ?? null }) +const active = task => !['accepted', 'cancelled', 'failed'].includes(task.status) + +export function install(rt) { + rt.projectEffects.register('compat.cutover', { run: row => ({ cutoverId: row.payload.cutoverId, count: row.payload.count, sha256: row.payload.sha256 }) }) + function recover() { + if (!rt.sharedProjectMode?.() || rt.readOnly?.() !== undefined || rt.state.projectCompatibility) return false + const cutoverId = randomUUID(), projects = {} + for (const project of Object.values(rt.state.projects ?? {})) { + const tasks = Object.values(rt.state.tasks ?? {}).filter(task => task.projectId === project.id && !task.projectTask).sort((a, b) => a.id.localeCompare(b.id)) + const sha256 = hash(tasks.map(identity)) + for (const task of tasks) task.legacyProject = { version: VERSION, cutoverId, ...identity(task) } + const effect = rt.projectEffects.stage({ kind: 'compat.cutover', projectId: project.id, key: cutoverId, payload: { cutoverId, count: tasks.length, sha256 } }) + projects[project.id] = { effect, count: tasks.length, sha256 } + } + rt.state.projectCompatibility = { version: VERSION, cutoverId, createdAt: Date.now(), projects } + return true + } + function committed(projectId, cutover, project) { + const effect = ownEntry(rt.state.projectEffects ?? {}, project?.effect?.id) + return Boolean(effect?.kind === 'compat.cutover' && effect.projectId === projectId && effect.payload.cutoverId === cutover?.cutoverId + && effect.payload.count === project.count && effect.payload.sha256 === project.sha256 && rt.projectEffects.intentCommitted(project.effect)) + } + function legacyAllowed(task) { + if (task.projectTask) return false + if (!task.projectId || !rt.sharedProjectMode?.() && !rt.state.projectCompatibility) return true + const cutover = rt.state.projectCompatibility, marker = task.legacyProject + const project = ownEntry(cutover?.projects ?? {}, task.projectId) + return Boolean(cutover?.version === VERSION && marker?.version === VERSION && marker.cutoverId === cutover.cutoverId + && hash(identity(task)) === hash({ taskId: marker.taskId, projectId: marker.projectId, requesterId: marker.requesterId, createdAt: marker.createdAt }) + && project && committed(task.projectId, cutover, project)) + } + function requireLegacy(task) { + if (!legacyAllowed(task)) throw fail('Project Task contract requires Chief admission; unknown post-cutover legacy work cannot execute') + } + function registerContinuation(original, child) { + requireLegacy(original) + if (!original.projectId || !rt.state.projectCompatibility) return + if (child.reworkOf !== original.id || child.projectId !== original.projectId || child.requesterId !== original.requesterId) throw fail('Legacy continuation identity changed') + child.legacyProject = { version: VERSION, cutoverId: rt.state.projectCompatibility.cutoverId, ...identity(child), derivedFrom: original.id } + } + function report(projectId, body, actor) { + rt.requireProjectAccess(actor, projectId) + const limit = body.limit ?? 50, offset = body.offset ?? 0 + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) throw fail('Invalid compatibility page') + const tasks = Object.values(rt.state.tasks ?? {}).filter(task => task.projectId === projectId).sort((a, b) => a.id.localeCompare(b.id)) + const classify = task => task.projectTask ? 'contract' : legacyAllowed(task) ? 'legacy' : 'held' + const totals = { contract: 0, legacy: 0, held: 0, active: 0 } + for (const task of tasks) { ++totals[classify(task)]; if (active(task)) ++totals.active } + const cutover = rt.state.projectCompatibility, proof = ownEntry(cutover?.projects ?? {}, projectId) + return { version: VERSION, cutoverId: cutover?.cutoverId ?? null, committed: committed(projectId, cutover, proof), + baseline: proof ? { count: proof.count, sha256: proof.sha256 } : null, totals, + tasks: tasks.slice(offset, offset + limit).map(task => ({ id: task.id, projectId, parentId: task.parentId ?? null, status: task.status, mode: classify(task), + attemptId: task.deliveryAttemptId ?? null, parentPresent: !task.parentId || ownEntry(rt.state.tasks ?? {}, task.parentId)?.projectId === projectId })), + page: { offset, limit, total: tasks.length, nextOffset: offset + limit < tasks.length ? offset + limit : null } } + } + rt.projectCompatibility = { recover, legacyAllowed, requireLegacy, registerContinuation, report } +} diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index 2b508229..b767a068 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -307,7 +307,9 @@ export function createProjectDelivery(rt, { publisher } = {}) { attempt.status = 'running' if (rt.taskDeliveryState?.(task.id, attempt.id, 'working', 'Delivery started') === false) throw reject('Task ended before delivery started') await rt.save() - const assigned = await rt.projectEndpoint('project-workflow', { projectId: project.id, operation: 'assign', workflowId: workflow.id, taskId: task.id }, actor) + const assigned = task.projectTask + ? await rt.assignProjectDeliveryWorkflow(attempt, actor) + : await rt.projectEndpoint('project-workflow', { projectId: project.id, operation: 'assign', workflowId: workflow.id, taskId: task.id }, actor) if (assigned.ok === false) throw reject(assigned.error) const run = assigned.workflows.find(row => row.id === workflow.id)?.run attempt.runId = run?.id ?? null diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index 9a8c29ca..464de165 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -31,7 +31,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -219,7 +219,7 @@ export function createDevelopmentService(rt) { if (!record(body) || body.version !== DEVELOPMENT_PROTOCOL || !['command', 'query'].includes(body.kind) || !record(body.payload ?? {})) throw failure('Unsupported Project development request') const { name, projectId } = body rt.requireProjectAccess(actor, projectId) - if (!Object.hasOwn(operations, name) && !['tasks', 'task', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'attempt.context', 'effects'].includes(name)) throw failure('Unknown Project development operation') + if (!Object.hasOwn(operations, name) && !['compatibility', 'tasks', 'task', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'attempt.context', 'effects'].includes(name)) throw failure('Unknown Project development operation') if (queries.has(name) !== (body.kind === 'query')) throw failure('Operation kind does not match the contract') if (body.kind === 'command' && !stableId(body.requestId)) throw failure('A command needs a stable requestId') const args = { ...(body.payload ?? {}), projectId, ...(body.requestId !== undefined ? { requestId: body.requestId } : {}) } @@ -255,6 +255,7 @@ export function createDevelopmentService(rt) { let result if (name === 'command.receipt') { if (!receipts) throw failure('Command receipts are unavailable'); result = receipts.get(projectId, args.commandRequestId, actor, args.commandName) } else if (name === 'command.result') { if (!receipts) throw failure('Command receipts are unavailable'); result = receipts.result(projectId, args, actor) } + else if (name === 'compatibility') result = rt.projectCompatibility.report(projectId, args, actor) else if (name === 'events') result = rt.committedProjectEvents.query(projectId, args) else if (name === 'project.snapshot') result = rt.committedProjectEvents.snapshot(projectId, args) else if (name === 'effects') result = { effects: rt.projectEffects.list(projectId, actor, args) } diff --git a/core/src/host/project-state-format.js b/core/src/host/project-state-format.js new file mode 100644 index 00000000..1292cf1f --- /dev/null +++ b/core/src/host/project-state-format.js @@ -0,0 +1,11 @@ +// A physical format fence prevents old JSON binaries from replaying new contracts. +const FORMAT = 'flybot-project-state/v1' +export function encodeProjectState(state) { + if (!state.projectCompatibility && !Object.values(state.tasks ?? {}).some(task => task.projectTask)) return state + return { ...state, version: 4, stateFormat: FORMAT, logicalVersion: 2 } +} +export function decodeProjectState(state) { + if (state?.version !== 4 || state.stateFormat !== FORMAT || state.logicalVersion !== 2) return state + const { stateFormat: _format, logicalVersion: _version, ...logical } = state + return { ...logical, version: 2 } +} diff --git a/core/src/host/project-state-migration.js b/core/src/host/project-state-migration.js index 775aa375..77ca1c9d 100644 --- a/core/src/host/project-state-migration.js +++ b/core/src/host/project-state-migration.js @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { existsSync, readFileSync, renameSync } from 'node:fs' import { join } from 'node:path' +import { encodeProjectState, decodeProjectState } from './project-state-format.js' import { homeLock } from './home-lock.js' import { openSqliteState } from './project-state-sqlite.js' import { writeDurableStateFile } from './project-state-storage.js' @@ -29,7 +30,7 @@ function snapshot(home, expectedMigrationId) { const state = db.read() if (state.version !== 2) throw fail('Unsupported exported state version') guardOf(home, expectedMigrationId) - return { text: JSON.stringify(state, null, 2), revision: metadata.revision } + return { text: JSON.stringify(encodeProjectState(state), null, 2), revision: metadata.revision } } finally { db.close() } } export function exportSqliteState({ home, output, expectedMigrationId }) { @@ -59,7 +60,7 @@ export function rollbackSqliteState({ home, expectedMigrationId, resume = false } if (marker.version !== 'flybot-storage-rollback/v1' || !/^[a-f0-9-]{36}$/.test(marker.id) || marker.archiveFile !== `state.sqlite.rollback-${marker.id}` || marker.exportFile !== `state.rollback-${marker.id}.json` || !/^[a-f0-9]{64}$/.test(marker.sha256)) throw fail('Rollback recovery marker is malformed') const text = readFileSync(join(home, marker.exportFile), 'utf8') - if (digest(text) !== marker.sha256 || JSON.parse(text).version !== 2) throw fail('Rollback export hash changed') + if (digest(text) !== marker.sha256 || decodeProjectState(JSON.parse(text)).version !== 2) throw fail('Rollback export hash changed') const currentText = readFileSync(statePath, 'utf8') if (digest(currentText) !== marker.sha256) { guardOf(home, expectedMigrationId) diff --git a/core/src/host/project-state-storage.js b/core/src/host/project-state-storage.js index fd9b4369..e48700da 100644 --- a/core/src/host/project-state-storage.js +++ b/core/src/host/project-state-storage.js @@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto' import { constants, closeSync, existsSync, fsyncSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { encodeProjectState, decodeProjectState } from './project-state-format.js' import { openSqliteState, requireSqliteRuntime } from './project-state-sqlite.js' const digest = value => createHash('sha256').update(value).digest('hex') @@ -54,7 +55,7 @@ export function createProjectStateStorage({ home, statePath = join(home, 'state. let text try { text = readFileSync(fd, 'utf8') } finally { closeSync(fd) } if (digest(text) !== proof.sha256) throw fail('SQLite import backup hash changed') - const state = JSON.parse(text) + const state = decodeProjectState(JSON.parse(text)) validate(state) return state } @@ -104,7 +105,7 @@ export function createProjectStateStorage({ home, statePath = join(home, 'state. ensureWriter(); validate(state); requireSqliteRuntime() guard = readGuard() if (!guard) { - const text = JSON.stringify(state), sha256 = digest(text), backup = `state.sqlite-import-${sha256}.json` + const text = JSON.stringify(encodeProjectState(state)), sha256 = digest(text), backup = `state.sqlite-import-${sha256}.json` writeDurableStateFile(join(home, backup), text, { exclusive: true }) guard = { version: 3, storage: 'sqlite', phase: 'prepared', migration: { id: randomUUID(), backup, sha256, importedAt: Date.now() } } // Guard first: a crash before SQL initialization remains recoverable from diff --git a/core/src/host/project-task-lifecycle.js b/core/src/host/project-task-lifecycle.js index 0bbb2d15..6522d3ab 100644 --- a/core/src/host/project-task-lifecycle.js +++ b/core/src/host/project-task-lifecycle.js @@ -195,7 +195,7 @@ export function createProjectTaskLifecycle(rt) { return task } function requireExecutable(task) { - if (!task.projectTask) return // Historical Tasks keep their original admission record. + if (!task.projectTask) { rt.projectCompatibility?.requireLegacy(task); return } const row = contract(task) if (row.admission.status !== 'accepted') throw fail('Project Task requires Chief admission') if (row.wait && ['waiting', 'ready'].includes(row.wait.status)) throw fail('Project Task is waiting for child results') diff --git a/core/src/host/store.js b/core/src/host/store.js index 459f32aa..00af6eee 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -2,6 +2,7 @@ // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. import { install as installProjects } from './project-service.js' +import { encodeProjectState, decodeProjectState } from './project-state-format.js' import { createProjectStateStorage } from './project-state-storage.js' import { createProjectServiceEvents } from './project-service-events.js' import { constants, mkdirSync, statSync } from 'node:fs' @@ -185,7 +186,7 @@ export function install(rt) { seen = stamp() let parsed try { - parsed = JSON.parse(contents) + parsed = decodeProjectState(JSON.parse(contents)) } catch (error) { const backup = `${statePath}.broken-${Date.now()}` try { @@ -215,6 +216,10 @@ export function install(rt) { return parsed } if (parsed?.version === STATE_VERSION) { + if (parsed.projectCompatibility && !rt.sharedProjectMode?.()) { + stuck = 'Project compatibility state requires projectWorkbench or controlPlane; preserve the shared access boundary' + return undefined + } const problem = ownershipProblem(parsed) if (problem !== undefined) { stuck = `malformed ownership: ${problem}` @@ -230,6 +235,7 @@ export function install(rt) { const validateSqliteState = parsed => { if (parsed?.version !== STATE_VERSION) throw new Error('Unsupported SQLite state version') + if (parsed.projectCompatibility && !rt.sharedProjectMode?.()) throw new Error('Project compatibility state requires projectWorkbench or controlPlane') const problem = ownershipProblem(parsed) if (problem !== undefined) throw new Error(`Malformed SQLite ownership: ${problem}`) } @@ -397,6 +403,7 @@ export function install(rt) { if (rt.projectScheduledTasks?.recover()) migrated = true if (rt.projectChiefControl?.recover()) migrated = true if (rt.projectEffects?.recover()) migrated = true + if (rt.projectCompatibility?.recover()) migrated = true if (syncDesignatedChief()) migrated = true indexSessions() committedEvents.load(state) @@ -565,7 +572,7 @@ export function install(rt) { const persistedSnapshot = JSON.stringify(prepared.snapshot, null, 2) if (storage.sqlite) storage.write(persistedSnapshot) else { - await writeFile(temp, persistedSnapshot, 'utf8') + await writeFile(temp, JSON.stringify(encodeProjectState(prepared.snapshot), null, 2), 'utf8') ensureLock() await rename(temp, statePath) } @@ -574,7 +581,7 @@ export function install(rt) { state.projectServiceEventStreams = structuredClone(prepared.snapshot.projectServiceEventStreams) state.projectServiceEvents = structuredClone(prepared.snapshot.projectServiceEvents) seen = stamp() - await journal?.record(persistedSnapshot) + await journal?.record(JSON.stringify(encodeProjectState(prepared.snapshot), null, 2)) rt.capabilitiesStateSaved?.() } catch (error) { await unlink(temp).catch(() => { /* the temp file may already be gone */ }) diff --git a/core/src/host/task-drive.js b/core/src/host/task-drive.js index ffa4a4d1..6b0f7c7e 100644 --- a/core/src/host/task-drive.js +++ b/core/src/host/task-drive.js @@ -95,7 +95,7 @@ export function install(rt) { // they had: retiring stops the delivery, it invents no new state. if (retiredBot(bot)) return [] return Object.values(rt.state.tasks ?? {}).filter((task) => { - if (task.projectTask || task.ownerBotId !== botId || task.waitingFor === 'input') return false + if (task.projectTask || rt.projectCompatibility && !rt.projectCompatibility.legacyAllowed(task) || task.ownerBotId !== botId || task.waitingFor === 'input') return false // An item with a role and no claimer belongs to the room, not to this Bot: the enqueuer // must not be woken for its own item (#1247). A claimed one names the member and is // driven above like any other card. @@ -299,7 +299,7 @@ export function install(rt) { async function offerQueue() { const byRoomRole = new Map() for (const task of Object.values(rt.state.tasks ?? {})) { - if (task.projectTask || !task?.queueRole || task.claimedBy || task.status !== 'open') continue + if (task.projectTask || rt.projectCompatibility && !rt.projectCompatibility.legacyAllowed(task) || !task?.queueRole || task.claimedBy || task.status !== 'open') continue const roomId = task.origin?.roomId const room = roomId ? rt.roomOf(roomId) : undefined if (roomId && (!room || room.origin === 'face-to-face')) continue diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 3c3fe7f1..84f3749c 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -273,6 +273,7 @@ export function install(rt) { const projectTransitions = new WeakSet() function transition(task, to, actor, extra) { if (task.projectTask && !projectTransitions.has(task)) throw forbidden('Use the Project Task lifecycle; Chief owns admission and final judgement') + if (!task.projectTask && !projectTransitions.has(task)) rt.projectCompatibility?.requireLegacy(task) if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`) if (waitingForInput(task) && to !== 'blocked') { delete task.waitingFor @@ -523,6 +524,7 @@ export function install(rt) { // endpoints all reach it, so a refusal is the same refusal wherever the value came from. The // reads happen before any assignment, so a refused write leaves the card exactly as it was. function writeContract(task, body = {}) { + if (task.projectTask && ['taskClass', 'reworkOf', 'handoff', 'lease', 'evidence'].some(key => body[key] !== undefined)) throw forbidden('Use the Project Task and Handoff contracts to change execution or evidence') const taskClass = readTaskClass(body.taskClass) const reworkOf = readReworkOf(body.reworkOf) const handoff = readHandoff(body.handoff) @@ -673,6 +675,7 @@ export function install(rt) { function expireLeases(now = Date.now()) { let expired = 0 for (const task of Object.values(store())) { + if (rt.projectCompatibility && !rt.projectCompatibility.legacyAllowed(task)) continue const lease = leaseOf(task) if (lease === undefined || !Number.isFinite(lease.expiresAt) || lease.expiresAt > now) continue // A card waiting for its rework is not abandoned work (#1804): the open child card is the @@ -707,6 +710,7 @@ export function install(rt) { // means "part of an overall card" and must never make `settleOverall` fire. A replay of the // same rejection reuses the child that is still open instead of creating a second one. function openRework(original, note, by = 'requester') { + rt.projectCompatibility?.requireLegacy(original) if (original.projectId) rt.projects?.requireActive(original.projectId) const reason = boundedLine(note, HANDOFF_REASON_MAX, 'handoff.reason') // A worker-side return names the role that produced the card (#1775); the requester's own @@ -743,6 +747,7 @@ export function install(rt) { actors: [{ status: 'open', at, by: 'rework opened' }], } rt.projects?.stampTask(child) + rt.projectCompatibility?.registerContinuation(original, child) store()[child.id] = child return child } @@ -849,6 +854,7 @@ export function install(rt) { // blocked → open is the queue's own edge: a member that cannot finish returns the item, and // no other path sends a card back to the queue, so the edge is not on TRANSITIONS. function requeue(task, reason, retryInSec, by = 'returned to the queue') { + rt.projectCompatibility?.requireLegacy(task) dropWorker(task.worker) delete task.worker delete task.waitingFor @@ -949,6 +955,7 @@ export function install(rt) { ? undefined : open.filter(task => cardNamesIssue(task, issue)).sort(newestFirst)[0] if (held) { + rt.projectCompatibility?.requireLegacy(held) held.note = text // Both paths below append to `actors`, and `record()` pushes without a guard, so a card // stored before the field existed would throw after the note moved and before the save @@ -985,6 +992,7 @@ export function install(rt) { } defaultContract(task) rt.projects?.stampTask(task) + rt.projectCompatibility?.requireLegacy(task) store()[task.id] = task await save() return view(task) @@ -1034,6 +1042,7 @@ export function install(rt) { await rt.load() const live = find(pick.id) if (live === undefined || live.status !== 'open' || live.claimedBy !== undefined) return { item: null, reason: 'taken' } + rt.projectCompatibility?.requireLegacy(live) live.claimedBy = botId live.ownerBotId = botId delete live.retryAt @@ -1076,6 +1085,7 @@ export function install(rt) { await rt.load() const live = find(pick.id) if (live === undefined || live.status !== 'open' || live.claimedBy !== undefined) return { item: null, reason: 'taken' } + rt.projectCompatibility?.requireLegacy(live) live.claimedBy = botId live.ownerBotId = botId delete live.retryAt @@ -1162,6 +1172,7 @@ export function install(rt) { } defaultContract(task) rt.projects?.stampTask(task) + rt.projectCompatibility?.requireLegacy(task) store()[task.id] = task return task } @@ -1241,6 +1252,7 @@ export function install(rt) { task = makeTask(bot, title, roomId, parent) } } + rt.projectCompatibility?.requireLegacy(task) if (!frozen) { if (parent && task.kind !== 'group') { task.parentId = parent.id @@ -1398,6 +1410,7 @@ export function install(rt) { // A plugin may claim an open card for a local Bot (#1318). The second save // does not ask again, so a listener cannot re-enter this write. async function routeOpenTask(task, kind) { + if (task.projectTask) return if (typeof rt.onTaskWrite !== 'function') return let route try { @@ -1547,6 +1560,7 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) if (!mayUpdate(task, actor)) throw forbidden('Only the requester or the Bot\'s owner may update this task') rt.projects?.validateTask(task, body) + if (!task.projectTask) rt.projectCompatibility?.requireLegacy(task) if (task.projectTask && ['title', 'criteria', 'impact', 'scope'].some(key => body[key] !== undefined && body[key] !== (key === 'title' || key === 'criteria' ? task[key] : task.projectTask.requirements[key]))) throw forbidden('Project requirements changes need Chief confirmation') writeContract(task, body) const { progress } = applyFields(task, body) diff --git a/core/src/host/workbench-personal.js b/core/src/host/workbench-personal.js index 14a196f4..35a3d321 100644 --- a/core/src/host/workbench-personal.js +++ b/core/src/host/workbench-personal.js @@ -7,8 +7,11 @@ const personalBot = bot => bot && !bot.projectAgent && !bot.ephemeral && !bot.pr export function personalRoster(rt, actor) { if (!actor?.memberId) throw refused() const bots = Object.values(rt.state.bots).filter(bot => personalBot(bot) && rt.botOwnedBy(actor, bot.id)) - const rooms = Object.values(rt.state.rooms).filter(room => !room.projectId && !room.face && (rt.roomMemberOf(actor, room.id) || rt.roomOwnedBy(actor, room.id)) && (room.members ?? []).every(id => personalBot(ownEntry(rt.state.bots, id)))) - return { bots: bots.map(({ id, name, role, instructions, model, color, avatar, parentId, sessionId }) => ({ id, name, role, instructions, model, color, avatar, parentId, sessionId: rt.chatOf?.(id) ?? sessionId ?? id, parts: (rt.partsOf?.(id) ?? []).map(part => ({ sessionId: part.sessionId, number: part.number })) })), rooms: rooms.map(({ id, name, members, participants, mode }) => ({ id, name, members, participants: (participants ?? []).map(({ id, kind, displayName, role, joinedSeq, leftSeq }) => ({ id, kind, displayName, role, joinedSeq, leftSeq })), mode })), mainBotIds: rt.state.mainBotIds.filter(id => bots.some(bot => bot.id === id)) } + const rooms = Object.values(rt.state.rooms).filter(room => (!room.projectId || rt.projectCan?.(actor, room.projectId, 'read')) && !room.face && (rt.roomMemberOf(actor, room.id) || rt.roomOwnedBy(actor, room.id)) && (room.members ?? []).every(id => { + const bot = ownEntry(rt.state.bots, id) + return bot && !bot.projectAgent && !bot.ephemeral && !bot.remote && (!bot.projectId || bot.projectId === room.projectId) + })) + return { bots: bots.map(({ id, name, role, instructions, model, color, avatar, parentId, sessionId }) => ({ id, name, role, instructions, model, color, avatar, parentId, sessionId: rt.chatOf?.(id) ?? sessionId ?? id, parts: (rt.partsOf?.(id) ?? []).map(part => ({ sessionId: part.sessionId, number: part.number })) })), rooms: rooms.map(({ id, name, members, participants, mode, projectId }) => ({ id, name, ...(projectId ? { projectId } : {}), members, participants: (participants ?? []).map(({ id, kind, displayName, role, joinedSeq, leftSeq }) => ({ id, kind, displayName, role, joinedSeq, leftSeq })), mode })), mainBotIds: rt.state.mainBotIds.filter(id => bots.some(bot => bot.id === id)) } } export function requirePersonalSession(rt, actor, id) { const roster = personalRoster(rt, actor) diff --git a/core/test/project-access.test.js b/core/test/project-access.test.js index 6303d2ef..e80c7862 100644 --- a/core/test/project-access.test.js +++ b/core/test/project-access.test.js @@ -11,13 +11,13 @@ import { fakeContext } from './fake-ctx.js' const alice = { memberId: 'owner-1' } const bob = { memberId: 'bob' } const outsider = { memberId: 'outsider' } -async function setup(t) { +async function setup(t, tasks = {}) { const home = mkdtempSync(join(tmpdir(), 'flybot-access-')) const state = { version: 2, mainBotIds: ['a'], members: { 'owner-1': { id: 'owner-1' }, bob: { id: 'bob' }, outsider: { id: 'outsider' } }, bots: { a: { id: 'a', name: 'Chief', ownerId: 'owner-1', role: 'Owner', sessionId: 'a', projectId: 'p1', createdAt: 1 } }, projects: { p1: { id: 'p1', name: 'Shared', ownerUserId: 'owner-1', status: 'active', createdAt: 1, updatedAt: 1, roles: { maintainers: [], members: ['bob'] } }, p2: { id: 'p2', name: 'Private', ownerUserId: 'outsider', status: 'active', createdAt: 1, updatedAt: 1 } }, - rooms: {}, tasks: {}, projectRuns: {}, exchanges: [], exchangeTurns: {}, agents: {} } + rooms: {}, tasks, projectRuns: {}, exchanges: [], exchangeTurns: {}, agents: {} } writeFileSync(join(home, 'state.json'), JSON.stringify(state)) const ctx = fakeContext() Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [] }, @@ -96,7 +96,9 @@ test('explicit null cannot detach a new shared Task from membership revocation', }) test('Workflow gateway isolates projects, serializes quota admission and makes duplicate starts harmless', async t => { - const rt = await setup(t) + // Compatibility graphs continue only pre-cutover work; new Tasks must use the controller. + const legacy = id => ({ id, projectId: 'p1', requesterId: 'bob', ownerBotId: 'a', title: id, criteria: 'Result verified', status: 'open', actors: [], createdAt: 1 }) + const rt = await setup(t, { first: legacy('first'), second: legacy('second') }) const service = createWorkflowService({ persist: false, readProject: id => rt.readProject(id), projectContext: (taskId, projectId) => rt.flybot.projects.context(taskId, projectId), bindRun: (id, projectId, details) => rt.flybot.projects.bindRun(id, projectId, details) }) @@ -114,13 +116,14 @@ test('Workflow gateway isolates projects, serializes quota admission and makes d const snapshot = await rt.projectEndpoint('project-workflow', { projectId: 'p1' }, bob) assert.deepEqual(snapshot.workflows.map(row => row.name), ['Shared graph']) await assert.rejects(create('p1', bob, 'Forbidden'), /access denied/) - const first = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p1', title: 'First', criteria: 'First result verified', impact: 'First Project change' }, bob) - const second = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p1', title: 'Second', criteria: 'Second result verified', impact: 'Second Project change' }, bob) + const first = rt.state.tasks.first, second = rt.state.tasks.second + const canonical = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p1', title: 'New work', criteria: 'New result verified', impact: 'New Project change' }, bob) await rt.projectEndpoint('project-quota', { id: 'p1', maxConcurrent: 1 }, alice) const start = taskId => rt.projectEndpoint('project-workflow', { projectId: 'p1', operation: 'assign', workflowId: graph.id, taskId }, bob) - await assert.rejects(start(first.id), /Chief admission/) + await assert.rejects(start(canonical.id), /Chief admission/) const { admitTask } = await import('../src/host/project-task-lifecycle.js') - for (const task of [first, second]) admitTask(rt.state.tasks[task.id], { id: 'admit-' + task.id, taskId: task.id, projectId: 'p1', executorRole: 'chief', runId: 'chief-fixture', initiatedBy: alice.memberId, reason: 'Scoped requirements fixture' }) + admitTask(rt.state.tasks[canonical.id], { id: 'admit-' + canonical.id, taskId: canonical.id, projectId: 'p1', executorRole: 'chief', runId: 'chief-fixture', initiatedBy: alice.memberId, reason: 'Scoped requirements fixture' }) + await assert.rejects(start(canonical.id), /task.run|controller/) rt.state.projectExecutions['chief-job'] = { id: 'chief-job', projectId: 'p1', kind: 'chief-control', status: 'running' } const results = await Promise.allSettled([start(first.id), start(second.id)]) assert.equal(results.filter(row => row.status === 'fulfilled').length, 1) diff --git a/core/test/project-compatibility.test.js b/core/test/project-compatibility.test.js new file mode 100644 index 00000000..87a79986 --- /dev/null +++ b/core/test/project-compatibility.test.js @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const actor = { memberId: 'owner-1' } +const seed = () => ({ version: 2, revision: 1, members: { 'owner-1': { id: 'owner-1', designatedChiefId: 'a' } }, mainBotIds: ['a'], + bots: { a: { id: 'a', ownerId: 'owner-1', sessionId: 'a', name: 'Chief', role: 'Chief' } }, + projects: { p: { id: 'p', name: 'Project', ownerUserId: 'owner-1', status: 'active', roles: { members: [], maintainers: [] } } }, + rooms: { g: { id: 'g', name: 'Group', admin: 'a', members: ['a'], sessions: {}, log: [], seq: 0, seen: {}, projectId: 'p' } }, + tasks: { old: { id: 'old', title: 'Existing work', projectId: 'p', ownerBotId: 'a', requesterId: 'owner-1', status: 'working', source: 'conversation', createdAt: 1, actors: [{ status: 'working', at: 1 }], origin: { roomId: 'g' } } } }) +async function fixture(t, state = seed()) { + const home = mkdtempSync(join(tmpdir(), 'flybot-compat-')), path = join(home, 'state.json') + writeFileSync(path, JSON.stringify(state)) + const hosts = [] + async function start(config = {}) { + const ctx = fakeContext() + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = options => { if (options.path.endsWith('bot')) route = options; return register(options) } + Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [] }, workspaceRegistry: { get: () => undefined, archiveSession: async () => {} }, + sessionController: { resolveAgent: async () => ({ agent: undefined }) }, sessions: { flush: async () => {} } }) + const rt = createRuntime(ctx, { home, projectWorkbench: true, ...config }) + let closed = false + const close = async () => { if (!closed) { closed = true; await ctx.dispose() } } + hosts.push(close); await rt.load() + return { rt, ctx, close, route } + } + t.after(async () => { for (const close of hosts.reverse()) await close(); rmSync(home, { recursive: true, force: true }) }) + return { home, path, start } +} + +test('shared group dispatch cannot create an unadmitted legacy Project Task', async t => { + const f = await fixture(t), { rt } = await f.start() + await assert.rejects(rt.assignFromDispatch({ roomId: 'g', botId: 'a', title: 'Change code', note: 'Implement a change', issue: 8 }), /Chief|Task.*contract/) + assert.deepEqual(Object.keys(rt.state.tasks), ['old']) +}) + +test('Bot tracking cannot create Project cards but personal tracking remains available', async t => { + const f = await fixture(t), { rt } = await f.start() + await assert.rejects(rt.trackBotTasks('a', { tasks: [{ title: 'Change code' }] }, { roomId: 'g' }), /Chief|Task.*contract/) + const personal = await rt.trackBotTasks('a', { tasks: [{ title: 'My reminder' }] }, {}) + assert.ok(personal) + const reminder = Object.values(rt.state.tasks).find(row => row.title === 'My reminder') + assert.equal(reminder.projectId, null) +}) + +test('cutover preserves identities/history, reloads once and refuses unknown later legacy work', async t => { + const f = await fixture(t), first = await f.start() + const old = first.rt.state.tasks.old + first.rt.projectTaskLifecycle.requireExecutable(old) + const marker = structuredClone(old.legacyProject) + assert.ok(marker?.cutoverId) + assert.deepEqual(old.actors, seed().tasks.old.actors) + first.rt.state.tasks.later = { ...structuredClone(old), id: 'later' } + assert.throws(() => first.rt.projectTaskLifecycle.requireExecutable(first.rt.state.tasks.later), /cutover|legacy|Chief/) + await first.rt.save(); await first.close() + const second = await f.start() + assert.deepEqual(second.rt.state.tasks.old.legacyProject, marker) + second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks.old) + assert.throws(() => second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks.later), /cutover|legacy|Chief/) + assert.equal(second.rt.state.members['owner-1'].designatedChiefId, 'a') + assert.equal(second.rt.state.bots.a.sessionId, 'a') + assert.equal(second.rt.state.rooms.g.projectId, 'p') + assert.equal(second.rt.state.tasks.old.requesterId, 'owner-1') +}) + +test('cutover state fences old JSON-only binaries while retaining logical Store version', async t => { + const f = await fixture(t), { rt } = await f.start() + await rt.save() + const disk = JSON.parse(readFileSync(f.path, 'utf8')) + assert.equal(disk.version, 4) + assert.equal(disk.stateFormat, 'flybot-project-state/v1') + assert.equal(rt.state.version, 2) +}) + +test('Project compatibility report is scoped, paginated and distinguishes unknown work without exposing content', async t => { + const state = seed() + state.tasks.child = { ...structuredClone(state.tasks.old), id: 'child', parentId: 'old', note: 'Private task material' } + state.tasks.personal = { ...structuredClone(state.tasks.old), id: 'personal', projectId: null } + const f = await fixture(t, state), { rt } = await f.start() + rt.state.tasks.unknown = { ...structuredClone(rt.state.tasks.old), id: 'unknown' } + const report = await rt.projectEndpoint('project-development', { version: 'project-development/v1', kind: 'query', name: 'compatibility', projectId: 'p', payload: { limit: 1 } }, actor) + assert.equal(report.result.totals.legacy, 2) + assert.equal(report.result.totals.held, 1) + assert.equal(report.result.baseline.count, 2) + assert.equal(report.result.committed, true) + assert.equal(report.result.tasks[0].id, 'child') + assert.equal(report.result.tasks[0].parentPresent, true) + assert.equal(report.result.page.nextOffset, 1) + assert.doesNotMatch(JSON.stringify(report), /Private task material|personal/) + assert.throws(() => rt.projectCompatibility.report('p', {}, { memberId: 'outsider' }), /Project|permission/) + assert.throws(() => rt.projectCompatibility.report('p', { limit: 101 }, actor), /page/) +}) + +test('an unconfirmed cutover cannot authorize legacy execution', async t => { + const f = await fixture(t), { rt } = await f.start() + const old = rt.state.tasks.old, proof = rt.state.projectCompatibility.projects.p + const original = proof.effect + proof.effect = { id: 'not-saved', generation: 1 } + assert.throws(() => rt.projectTaskLifecycle.requireExecutable(old), /cutover|legacy|Chief/) + proof.effect = original + old.requesterId = 'another-user' + assert.throws(() => rt.projectTaskLifecycle.requireExecutable(old), /cutover|legacy|Chief/) +}) + +test('cutover cannot be reopened by disabling shared authorization', async t => { + for (const stateStorage of ['json', 'sqlite']) { + const f = await fixture(t), first = await f.start({ stateStorage }) + await first.rt.save(); await first.close() + const before = readFileSync(f.path, 'utf8') + const next = await f.start({ projectWorkbench: false }) + assert.match(next.rt.readOnly(), /projectWorkbench|controlPlane/) + await next.rt.save() + assert.equal(readFileSync(f.path, 'utf8'), before) + } +}) + +test('SQLite export and rollback retain the cutover fence and do not replay committed external actions', async t => { + const { exportSqliteState, rollbackSqliteState } = await import('../src/host/project-state-migration.js') + const f = await fixture(t), first = await f.start({ stateStorage: 'sqlite' }), actionPath = join(f.home, 'external-result.txt') + let executions = 0 + first.rt.projectEffects.register('fixture.action', { run() { ++executions; writeFileSync(actionPath, 'Original committed result'); return { published: true } } }) + await first.rt.projectEffects.perform({ kind: 'fixture.action', key: 'one-action', projectId: 'p', taskId: 'old', payload: { reference: 'immutable' } }) + const migrationId = first.rt.stateStorage.status().migration.id + await first.close() + const output = join(f.home, 'export.json') + exportSqliteState({ home: f.home, output, expectedMigrationId: migrationId }) + assert.equal(JSON.parse(readFileSync(output, 'utf8')).version, 4) + rollbackSqliteState({ home: f.home, expectedMigrationId: migrationId }) + assert.equal(JSON.parse(readFileSync(f.path, 'utf8')).version, 4) + const second = await f.start({ stateStorage: 'json' }) + second.rt.projectEffects.register('fixture.action', { run() { ++executions; throw new Error('Must never replay') } }) + second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks.old) + await second.rt.projectEffects.drain() + assert.equal(executions, 1) + assert.equal(readFileSync(actionPath, 'utf8'), 'Original committed result') + assert.equal(second.rt.state.tasks.old.id, 'old') + assert.equal(second.rt.state.tasks.old.ownerBotId, 'a') +}) + +test('unknown state frames remain read-only without destroying existing records', async t => { + for (const frame of [{ version: 5 }, { version: 4, stateFormat: 'unknown', logicalVersion: 2 }, { version: 4, stateFormat: 'flybot-project-state/v1', logicalVersion: 9 }]) { + const f = await fixture(t, { ...seed(), ...frame }), before = readFileSync(f.path, 'utf8') + const { rt } = await f.start() + assert.match(rt.readOnly(), /newer FlyBot/) + await rt.save() + assert.equal(readFileSync(f.path, 'utf8'), before) + } +}) + +test('a later group binding does not retroactively assign personal Tasks or their private history', async t => { + const state = seed() + delete state.rooms.g.projectId + state.tasks.old.projectId = null + const f = await fixture(t, state), { rt } = await f.start() + await rt.projectEndpoint('project-bind', { kind: 'group', id: 'g', projectId: 'p' }, actor) + assert.equal(rt.state.tasks.old.projectId, null) + assert.equal(rt.state.tasks.old.legacyProject, undefined) + assert.equal(rt.state.bots.a.projectId, undefined) + assert.equal(rt.state.members['owner-1'].designatedChiefId, 'a') + assert.equal(rt.state.projectCompatibility.projects.p.count, 0) + assert.equal(rt.projectCompatibility.legacyAllowed(rt.state.tasks.old), true) +}) + +test('original Chief and Project-linked group use the authenticated original channel without manufacturing Tasks', async t => { + const state = seed() + state.members.bob = { id: 'bob' }; state.projects.p.roles.members.push('bob') + state.rooms.g.participants = [{ id: 'owner-1', kind: 'human', role: 'admin', joinedSeq: 0, joinedAt: 1 }, { id: 'a', kind: 'bot', role: 'admin', joinedSeq: 0, joinedAt: 1 }] + const f = await fixture(t, state), { rt, route, ctx } = await f.start() + rt.authenticate = async token => token === 'owner' ? actor : { memberId: 'bob' } + const messages = [] + rt.resolveAgent = async id => ({ session: { id }, status: 'idle', followup: message => messages.push(message), steer: message => messages.push(message) }) + rt.ensureCapabilities = async () => {} + ctx.sessionController.prompt = async input => messages.push(input) + rt.live.set('a', { turn: 1 }) + const call = async (operation, id, token = 'owner', extra = {}) => (await route.fetch(new Request('http://localhost/api/bot', { + method: 'POST', headers: { 'x-flybot-member-session': token }, body: JSON.stringify({ endpoint: 'personal-space', payload: { operation, id, ...extra } }) }))).json() + const before = Object.keys(rt.state.tasks) + assert.equal((await call('list')).value.mainBotIds[0], 'a') + assert.equal((await call('list')).value.rooms[0].projectId, 'p') + assert.equal((await call('send', 'a', 'owner', { text: 'Explain the current plan' })).ok, true) + assert.equal(messages.length, 1) + await rt.postRoomMessage('g', { text: 'A group question', displayName: 'Owner' }, actor) + assert.equal((await call('read', 'g')).value.messages.at(-1).text, 'A group question') + assert.equal((await call('read', 'g', 'bob')).ok, false, 'Project membership cannot grant private group history') + assert.deepEqual(Object.keys(rt.state.tasks), before) + rt.memoryEndpoint = async () => ({ entries: [{ scope: 'own', text: 'Private Chief memory' }, { scope: 'team', text: 'Unrelated memory' }] }) + assert.deepEqual((await call('memory', 'a')).value.entries, [{ scope: 'own', text: 'Private Chief memory' }]) + assert.equal((await call('memory', 'a', 'bob')).ok, false) + assert.equal(ctx.calls.includes('on llm/stream'), true) +}) + +test('legacy rework retains the same lineage and survives reload without admitting unrelated work', async t => { + const state = seed() + state.tasks.old.status = 'result-ready' + const f = await fixture(t, state), first = await f.start() + await first.rt.taskEndpoint('task-send-back', { id: 'old', note: 'Repair the original result' }, actor) + const child = Object.values(first.rt.state.tasks).find(row => row.reworkOf === 'old') + assert.ok(child) + assert.equal(child.projectId, 'p') + assert.equal(child.legacyProject.derivedFrom, 'old') + first.rt.projectTaskLifecycle.requireExecutable(child) + await first.close() + const second = await f.start() + second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks[child.id]) + assert.equal(second.rt.state.tasks[child.id].reworkOf, 'old') + assert.equal(second.rt.state.projectCompatibility.projects.p.count, 1) +}) + +test('canonical Tasks cannot inject old card leases, Handoffs or plugin routing', async t => { + const f = await fixture(t), { rt } = await f.start() + await rt.projectEndpoint('project-bind', { kind: 'bot', id: 'a', projectId: 'p' }, actor) + const task = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p', title: 'Change the code', criteria: 'Regression passes', impact: 'Fix Project behavior' }, actor) + const row = rt.state.tasks[task.id], before = structuredClone(row) + for (const payload of [{ handoff: { from: 'Chief', to: 'Dev' } }, { lease: { holder: 'someone', takenAt: 1, expiresAt: 99999 } }, { evidence: [{ check: 'invented PASS' }] }]) { + await assert.rejects(rt.taskEndpoint('task-update', { id: row.id, ...payload }, actor), /contracts/) + assert.deepEqual(row, before) + } + let routing = 0 + rt.onTaskWrite = () => { ++routing; return { ownerBotId: 'someone' } } + await rt.taskEndpoint('task-update', { id: row.id, note: 'Additional requirement detail' }, actor) + assert.equal(routing, 0) + assert.equal(row.ownerBotId, 'a') +}) diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index c8fc9656..2e4b7994 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -1378,6 +1378,7 @@ test('new Project Tasks wait for Chief admission and cannot run or lower require admitTask(row, { id: 'admit-fixture', projectId: 'p1', taskId: row.id, executorRole: 'chief', runId: 'chief-fixture', initiatedBy: alice.memberId, reason: 'Goal, impact and acceptance checked' }) const started = await f.start('accepted') assert.equal(started.attempt.status, 'running') + await assert.rejects(f.rt.projectEndpoint('project-workflow', { projectId: 'p1', operation: 'assign', workflowId: started.attempt.workflowId, taskId: row.id }, bob), /task.run|controller/) await complete(f) await assert.rejects(f.rt.projectEndpoint('project-task-accept', { attemptId: f.attempt().id }, alice), /Chief/) }) diff --git a/core/test/project-effects-runtime.test.js b/core/test/project-effects-runtime.test.js index 661d72a6..41b7a8ef 100644 --- a/core/test/project-effects-runtime.test.js +++ b/core/test/project-effects-runtime.test.js @@ -34,7 +34,7 @@ test('real store rename acknowledges the same handoff/claim before any external assert.equal(f.executed(), 1) assert.equal(f.dispatched(), 1) const disk = JSON.parse(readFileSync(join(f.home, 'state.json'), 'utf8')) - assert.equal(Object.values(disk.projectEffects)[0].status, 'done') + assert.equal(Object.values(disk.projectEffects).find(row => row.taskId === 'task' && row.payload.handoffId === disk.tasks.task.collaboration.currentHandoffId).status, 'done') assert.equal(disk.tasks.task.collaboration.claim.workerId, 'controlled-worker') const result = await f.rt.projectEndpoint('project-development', { version: 'project-development/v1', kind: 'query', name: 'effects', projectId: 'p', payload: { taskId: 'task' } }, { memberId: 'owner-1' }) assert.equal(result.result.effects[0].committed, true) @@ -43,6 +43,7 @@ test('real store rename acknowledges the same handoff/claim before any external test('failed real handoff commit rolls back its offer and cannot be rescued by a timer', async t => { const f = await fixture(t), save = f.rt.save + const before = Object.keys(f.rt.state.projectEffects).sort() f.rt.save = async () => { throw Error('Injected write failure') } await assert.rejects(f.rt.projectPools.admit({ id: 'attempt', taskId: 'task' }, { memberId: 'owner-1' }), /write failure/) f.rt.save = save @@ -50,7 +51,7 @@ test('failed real handoff commit rolls back its offer and cannot be rescued by a assert.equal(f.executed(), 0) assert.equal(f.dispatched(), 0) assert.equal(f.rt.state.tasks.task.collaboration.history.length, 0) - assert.equal(Object.keys(f.rt.state.projectEffects).length, 0) + assert.deepEqual(Object.keys(f.rt.state.projectEffects).sort(), before) }) test('v1 Task input commits the inbox and notify intent before returning and creates no Task or worker', async t => { diff --git a/core/test/workbench-personal.test.js b/core/test/workbench-personal.test.js index 6d74e8fe..04296cc0 100644 --- a/core/test/workbench-personal.test.js +++ b/core/test/workbench-personal.test.js @@ -193,3 +193,22 @@ test('native file registration prevalidates the complete batch and preserves nat assert.equal(content[1].attachment.name, 'spec.txt') assert.deepEqual(saved, [{ name: 'spec.txt', data: 'SGk=' }]) }) + +test('Project-associated original groups retain their history ACL and require current Project access', () => { + const actor = { memberId: 'alice' }, state = { mainBotIds: ['chief'], bots: { chief: { id: 'chief', ownerId: 'alice' } }, rooms: { + own: { id: 'own', members: ['chief'], projectId: 'p', participants: [{ id: 'alice', kind: 'human', displayName: 'Alice' }] }, + other: { id: 'other', members: ['chief'], projectId: 'p' }, personal: { id: 'personal', members: ['chief'] } } } + let member = true + const rt = { state, botOwnedBy: (who, id) => state.bots[id]?.ownerId === who.memberId, roomMemberOf: (who, id) => who.memberId === 'alice' && id !== 'other', roomOwnedBy: () => false, + projectCan: () => member, botOf: id => state.bots[id], roomOf: id => state.rooms[id] } + assert.deepEqual(personalRoster(rt, actor).rooms.map(row => row.id), ['own', 'personal']) + assert.equal(requirePersonalSession(rt, actor, 'own'), 'own') + assert.equal(personalRoster(rt, actor).rooms[0].projectId, 'p') + assert.throws(() => requirePersonalSession(rt, { memberId: 'project-only' }, 'own'), /personal conversation/) + state.bots.chief.projectId = 'p' + assert.equal(requirePersonalSession(rt, actor, 'own'), 'own', 'original Project-bound group Bots retain the native group entry') + delete state.bots.chief.projectId + member = false + assert.throws(() => requirePersonalSession(rt, actor, 'own'), /personal conversation/) + assert.equal(requirePersonalSession(rt, actor, 'personal'), 'personal') +}) diff --git a/docs/architecture.md b/docs/architecture.md index 74bc6fdb..48080474 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -199,3 +199,7 @@ Project role governance (#2171) separates resident Chief proposals, Maintainer/O Native execution (#2184) uses the original Workflow lifecycle and SessionController, with exact Task-role physical bindings and durable quiescence facts. A new ephemeral carrier can restore prior context without reviving a deleted Bot identity; new input is fenced during stopping and resources are never released from Bot absence alone. See [native adapter](subsystems/project-harness.md#native-physical-session-binding-and-quiescence-2184). The committed service read model now covers native execution and public role-governance facts alongside SDK/Task records. Bounded entity-specific snapshot pages share a Project revision/cursor; live Task queries use committed role catalogs and existing activation/exit proofs. Exact frozen definitions remain referenced sources. + +## Original capability compatibility (#2186) + +The [compatibility adapter](subsystems/project-compatibility.md) preserves original personal Chief/Bots/groups and Task/Workflow IDs. New Project work enters the existing Chief/Handoff controller; a committed one-time legacy baseline protects old work without treating arbitrary untyped rows as admitted. A physical JSON format fence survives SQLite storage rollback. diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 5b8a200e..ef933e97 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -69,3 +69,5 @@ Persistent Project effects and GitHub receipts: [project-effects](project-effect - [Project context](project-context.md) — scoped budgets, working summaries, memory versions and bounded exact-source reads. - [Project Task lifecycle](project-task-lifecycle.md) — Chief admission, requirement versions, child waits and original-role continuation. + +- [Original FlyBot compatibility](project-compatibility.md) — original capability inventory, one-time legacy Task cutover, private group ACL and same-version storage rollback. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 967fdbf4..70da02cc 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -976,3 +976,7 @@ The original Store supports opt-in `stateStorage: sqlite` through the [storage b ## Committed interfaces (#2185) Store builds safe per-Project event projections from each queued immutable snapshot, persists their cursor state with business records/Effects, and publishes the committed cache after rename/COMMIT. Failed saves publish nothing. Public snapshots/events omit private agent/auth/receipt data. Task construction stamps trusted command provenance once through the application context; body actor fields cannot forge it. The [contract](project-service-contracts.md) separates public requests, private receipts, host-only role authority and exact bounded history. + +## Project compatibility (#2186) + +The [compatibility contract](project-compatibility.md) inventories original entrypoints and persistent IDs. Store records the legacy boundary only after Effects recovery; Task card constructors/drivers respect it. Original personal Chief and group channels stay in use. Associated native groups require both original history ACL and current Project membership, without importing private history. diff --git a/docs/subsystems/project-access.md b/docs/subsystems/project-access.md index 92ad8f4f..eef7a1e7 100644 --- a/docs/subsystems/project-access.md +++ b/docs/subsystems/project-access.md @@ -264,3 +264,7 @@ never a public HTTP actor. Legacy Chief question/consultation records remain pri New-contract Tasks cannot bypass Chief admission through legacy Project Workflow assign. Project concurrency counts execution Runs, not resident Chief control jobs, and excludes a waiting parent only after its registered leases and ephemeral workers have released. Legacy Workflow quota checks and default pool scheduling share that distinction. #2171 exposes project-roles/project-role-proposals as current-member reads and project-role-decision as a configure operation. The domain service independently checks exact proposal hash, current Maintainer/Owner authority and Chief self-approval restrictions after gateway admission. + +## Compatibility assignment boundary (#2186) + +Public Workflow assign cannot take canonical Project Task initiative. An internal current-Attempt port preserves explicit Workflow execution through the same membership/quota service; old persisted Workflow Tasks retain their prior path. Scoped development query compatibility reports the committed legacy baseline and current diagnostic counts. See [compatibility](project-compatibility.md). diff --git a/docs/subsystems/project-compatibility.md b/docs/subsystems/project-compatibility.md new file mode 100644 index 00000000..e84de724 --- /dev/null +++ b/docs/subsystems/project-compatibility.md @@ -0,0 +1,103 @@ +--- +subsystem: project-compatibility +owned_paths: + - core/src/host/project-compatibility.js + - core/src/host/project-state-format.js + - core/test/project-compatibility.test.js +--- + +# Original FlyBot compatibility and Project cutover + +The original Store owns every Task, Bot, group, Session and Workflow record. This +adapter registers pre-existing Project work once; it adds neither a second Task +store nor an executor. Personal conversations remain independent of Project. + +## Capability and data inventory + +| Object / entry | Source of truth and identity | Reuse / adaptation | +| --- | --- | --- | +| Personal Chief / Bot | members.designatedChiefId, bots, original root Session and parts | Original personal-space channel, persona, attachments, questions, approvals, stop, history and own memory; no forced Project binding | +| Independent personal agent | personalAgents and participations; original private carrier | Existing native identity/join/delegation and scoped FBA transport; external descriptors are not a verified vendor adapter | +| Native group | rooms, typed participants and their history windows, original group Session | Original group controls and message history; explicit Project association adds current Project read access to the existing group ACL | +| Project room | projectRooms and its own scoped messages/participants | Original Project-room API, role catalog and document controls; no personal history import | +| Public Project Bot | committed role catalog, published role memory and scoped public carrier | Existing project-agents/roles/memory reads and original Bot presentation; private Chief/agent memory remains private | +| Task / parent / rework | tasks by original id, parentId/reworkOf, actors/results | Same records; pre-cutover legacy work may continue, canonical work uses Chief admission and durable Handoff | +| Workflow / Run / artifacts | Original Workflow plugin and projectExecutions/Attempts/workspaces | Existing graphs remain explicit compatibility execution; new Project work defaults to pools, with one controlling Attempt | +| UI dependencies | Original personal-space, Task/Project endpoints and versioned development API | Reuse current surfaces; no client or control-plane replacement in this issue | +| Active native / SDK execution | Original workers, role Sessions, leases and Effects | Existing checkpoint/exit recovery; cutover itself starts, stops or reallocates nothing | + +A Project association does not invite people into a private group, publish its +history as Project knowledge, move its prior personal Tasks, change its Bots' +ownership, or turn its personal Chief into the Project Chief. A Project member +without the original group ACL cannot read the group. Revoked Project membership +also removes the associated group from the personal projection. + +## One-time admission boundary + +On the first writable shared-mode load, after original identity/Project migration +and Effects recovery, the adapter captures each existing Project's legacy Tasks. +It adds an identity-bound `legacyProject` marker to the **same** Task row and a +`projectCompatibility/v1` baseline with count and SHA-256 over sorted identities. +Each baseline has an original `compat.cutover` Effect; execution requires actual +Store commit acknowledgement and matching Effect Project/payload, not a save +promise, timestamp, boolean, or mere absence of `projectTask`. A read-only load +cannot create a baseline. A pending write remains held until committed. + +Subsequent loads never recapture newly found untyped Project rows. Unknown work +stays readable as `held` and cannot run through task.run, the old card driver, +queue claims or generic card state transitions. It requires explicit Chief +adjudication of the same inactive Task through the existing lifecycle; active +legacy work cannot change its admission contract. No forced rerun or ID mapping. +Known legacy rework retains `reworkOf`, the original requester and Project, and an +explicit derived marker, preserving the original return workflow across restart. + +New shared Project work must enter task.propose / task-create and Chief intake. +Native group dispatch/track_tasks cannot silently insert new legacy Project cards; +they explain the required Chief contract. The group's original speaker fallback +still answers messages when dispatch cannot create a card. Ordinary personal and +group questions do not publish Tasks. Unbound personal tracking stays available. +Canonical Tasks cannot inject old card leases, evidence or handoff fields, or be +rerouted by generic plugin onWrite. Public Workflow assign cannot acquire their +execution initiative. The delivery controller retains a private exact-current- +Attempt assignment port for an explicitly configured legacy Workflow; it still +uses original authorization, quota, Run and evidence checks. No fixed DAG default. + +## Inspection, storage and rollback + +Authenticated `project-development/v1` query `compatibility` takes projectId and +optional payload `{offset, limit}` (default50, maximum100). It returns the scoped +baseline/commit status, contract/legacy/held/active counts and ID/status/reference +rows with same-Project parent existence. It never copies descriptions, private +memory, authentication, entire histories or another Project's identities. This is +a derived diagnostic, not an authority or a second writable task board. Baseline +counts cover initial work; derived rework appears in current counts separately. + +JSON homes with a cutover or canonical Task use physical version4, +`stateFormat: flybot-project-state/v1`, `logicalVersion: 2`; the existing Store +retains logical state version2. Old JSON binaries refuse the newer frame rather +than replaying new contracts. Unknown/future frames stay read-only. Cutover homes +cannot reopen with shared authorization disabled. Personal-only homes retain the +original JSON version2 format. + +SQLite keeps its original version3 migration guard and logical version2 database. +Import backup, export and offline storage rollback preserve the version4 JSON +fence where applicable. They use the existing exclusive lock, exact migration +identity/hash, revision checks and recoverable rollback marker. A storage rollback +is **not** a code downgrade permit. Never delete the fence or manually reset an +Effect/lease to make old code accept new facts. Committed external actions retain +their receipts and are not rerun by export/restart/rollback. No runtime dual write. +Use a private offline snapshot and the same-version migration CLI before a live +cutover. Compare the scoped baseline, current counts, IDs/parent links and original +authorization before expanding callers; unknown exits remain held under #2184. + +## Evidence and limits + +project-compatibility.test.js exercises original runtime/channel and actual JSON/ +SQLite migration/rollback with a real local-file Effect, including restart, +unknown frames, baseline identity changes, post-cutover rows, original Chief and +group history ACL, rework and canonical card bypasses. workbench-personal and +Project delivery regressions keep original controls and legacy Workflow evidence. +The composed DSH runtime probes use deterministic inference, including native +return and SQLite; they are not real-provider development or browser acceptance. +No production migration, service restart, external-vendor product integration or +control-plane/frontend changes are performed by this PR. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index 8a9985bb..7ce8ab80 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -542,3 +542,7 @@ The pool host release adapter now delegates to one physical-exit query for SDK a Current canonical execution is revalidated through one authority helper before new input and by the existing scheduler before resource reconciliation/new dispatch. Revocation reuses the pool lifecycle stop, with an exact claim fence, visible blocked reason and confirmed-exit requirement. Role snapshots retain conventions while current rights constrain their use. Composed #2179 execution checks include the canonical Task requirement revision. Chief revision invalidates an old Attempt for further execution; the existing pool stop retains the logical Session and requires actual exit before routing a successor Attempt. This covers scope/impact changes as well as changed title/criteria, and complements existing stale-evidence checks at sealing/publication. Custom Developer-capability parent roles also use the existing child-source admission and workspace producer mapping. + +## Original Workflow compatibility (#2186) + +Explicit canonical Workflow delivery assigns only through the private exact-current-Attempt access port, preserving the original graph, quota and Run evidence. Public assign cannot create a parallel canonical execution. Pre-cutover Task IDs, parents, artifacts and active recovery remain authoritative. See [compatibility](project-compatibility.md). diff --git a/docs/subsystems/project-development-service.md b/docs/subsystems/project-development-service.md index 0f25038a..228a223b 100644 --- a/docs/subsystems/project-development-service.md +++ b/docs/subsystems/project-development-service.md @@ -212,3 +212,7 @@ Attempt lists retain at most32 role Session slots and three historical Sessions Committed Project events include role catalog revisions, public role-governance proposal statuses and native execution activation/exit facts. Private personal-agent proposals/conversations remain excluded. `project.snapshot` retains its default Task page; callers may select `payload.entityType` as `task`, `attempt`, `execution`, `resource`, `input` or `role-proposal` to rebuild other public read-model pages. Non-Task pages return `entities`; all types share the Project revision/event cursor and existing1–100 limit. Continuing pages requires the same `expectedRevision`; unknown/private entity types are rejected. After expiry fetch fresh snapshot pages and resume from that Project cursor. Receiving any snapshot/event grants no execution or approval authority. Legacy Projects with an implicit built-in catalog report its effective revision1 without materializing it on read. Queries use the raw committed governance catalog, not full Skill/memory execution-context resolution. Explicit configuration/proposals persist through the existing write path. A Project/role read must not create an otherwise unrelated Project change event on the next command. + +## Compatibility diagnostic (#2186) + +Query compatibility returns the same Project's committed original-work baseline, current contract/legacy/held/active counts and bounded ID/reference rows; payload offset defaults0 and limit defaults50 (maximum100). It reads existing Task/Effects facts without migration or content disclosure. See [compatibility](project-compatibility.md). diff --git a/docs/subsystems/project-effects.md b/docs/subsystems/project-effects.md index c807cb5c..cc1058c6 100644 --- a/docs/subsystems/project-effects.md +++ b/docs/subsystems/project-effects.md @@ -145,3 +145,7 @@ this proof before using pins; a successful/no-op save alone is not enough. This metadata-only adapter does not launch code or imply a merge/verification result. #2185 adds pure pool.accept and service.command commit markers. They acknowledge ownership/command phases without invoking another executor. service.command results bind their exact SHA256; marker list/retry visibility follows the receipt's original independent principal, including native/external personal-agent identity across carrier rotation. Other Project members do not gain access to private command receipts or their markers. + +## Compatibility cutover (#2186) + +The original Store also commits identity-only compat.cutover intents. Runtime handoff failure regressions compare exact pre-existing intent IDs, preserving this unrelated baseline while proving the failed Task offer/dispatch leaves no new effect. They locate success by the actual Task/Handoff ID rather than map insertion order. diff --git a/docs/subsystems/project-state-storage.md b/docs/subsystems/project-state-storage.md index 650b79bb..725ad47a 100644 --- a/docs/subsystems/project-state-storage.md +++ b/docs/subsystems/project-state-storage.md @@ -141,3 +141,7 @@ history service boundaries remain #2185; legacy entrypoint cutover remains #2186 The actual DSH native pool return probe also runs with `stateStorage: sqlite`. Its read side loads committed snapshots through a separate read-only SQLite connection, proving canonical Chief admission, native activation intent, retained Task-role context/parts, physical exit proofs and nonoverlapping resource release persist across role handoffs. It uses deterministic model transport and actual code/host tests, not provider inference or production migration. JSON remains the default. The dev probe's `--sqlite` flag is verification tooling; deployment documentation remains #2189. State maps have no semantic insertion order. The probe resolves the previous Attempt through the successor's `feedback.previousAttemptId`; SQLite map iteration exposed an invalid earlier assertion that assumed the first UUID record was oldest. Immutable history arrays preserve explicit positions. No domain fact or history was lost by that diagnostic failure. + +## Compatibility format fence (#2186) + +The original Store remains logical version2. Cutover/canonical JSON snapshots use known physical version4; SQLite import backup, export and storage rollback encode that fence and decode it before logical validation. Unknown frames remain read-only. Storage rollback does not authorize an older-code downgrade or receipt replay. See [compatibility](project-compatibility.md). diff --git a/docs/subsystems/project-task-lifecycle.md b/docs/subsystems/project-task-lifecycle.md index 0ef9f527..5957b160 100644 --- a/docs/subsystems/project-task-lifecycle.md +++ b/docs/subsystems/project-task-lifecycle.md @@ -111,3 +111,7 @@ Task-role context remains stable. The accepted-child source admission protocol i specified in project-delivery.md. A plan changed after a parent has resumed requires execution stopping/reconciliation; current guards refuse stale integration rather than pretending the changed wait has completed. + +## Legacy admission boundary (#2186) + +requireExecutable accepts an untyped shared Project Task only when the compatibility service recognizes its committed original identity. Absence of projectTask is no longer implicit admission. Explicit Chief adoption of inactive legacy work remains the existing adjudication capability; active work cannot change its contract. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 2d2cfb57..00597d59 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -183,3 +183,7 @@ send and uses the existing failure/release path; SDK dispatch retains its origin host adapter. No graph, task-pool or UI replacement is introduced. #2184 fixes native lifecycle input delivery: structured inputs use JSON rather than implicit [object Object] conversion, preserving handoff IDs/evidence and false/zero values. Distinct returned handoffs therefore produce distinct content for the existing delivery deduplicator, while an exact repeated dispatch remains stable. The regression uses the original plugin lifecycle seam; the real DSH native-pool probe reproduces and guards same-Session return delivery. No alternate Workflow or scheduler is introduced. + +## Canonical Project cutover (#2186) + +The original onWrite hook also refuses canonical Project Tasks if shared mode is disabled, preventing a new contract from reentering legacy auto-ingest. Original personal/legacy Workflow behavior and IDs remain. Explicit Project Workflow delivery uses the host current-Attempt port; pools remain the new Project default. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 1b1b083c..c99ce5be 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -3,11 +3,12 @@ FlyBot patch 0002-flybot-api-host on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gen diff --git a/index.js b/index.js --- a/index.js +++ b/index.js -@@ -4,9 +4,41 @@ +@@ -4,9 +4,42 @@ import { join } from 'node:path' import * as diagnostics from './src/host/diagnostics.js' import * as store from './src/host/store.js' +import * as projectEffects from './src/host/project-effects.js' ++import * as projectCompatibility from './src/host/project-compatibility.js' +import * as projectInputs from './src/host/project-inputs.js' +import * as projectSources from './src/host/project-sources.js' +import * as projectExecutionBoundary from './src/host/project-execution-boundary.js' @@ -80,7 +81,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, projectEffects, projectTaskLifecycle, projectInputs, harness, memberAuth, platformIdentity, projectAccess, projectDevelopment, projectExecutionBoundary, projectResources, projectHarness, projectRuntimeSetup, projectDelivery, projectDelegations, projectAgents, projectCredentials, projectGithubRead, projectPools, projectScheduledTasks, projectSources, projectChiefControl, projectScheduler, workbenchState, authz, personalAgentAuth, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] ++const MODULES = [diagnostics, store, projectEffects, projectCompatibility, projectTaskLifecycle, projectInputs, harness, memberAuth, platformIdentity, projectAccess, projectDevelopment, projectExecutionBoundary, projectResources, projectHarness, projectRuntimeSetup, projectDelivery, projectDelegations, projectAgents, projectCredentials, projectGithubRead, projectPools, projectScheduledTasks, projectSources, projectChiefControl, projectScheduler, workbenchState, authz, personalAgentAuth, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -699,12 +700,13 @@ diff --git a/src/host/roster.js b/src/host/roster.js diff --git a/src/host/store.js b/src/host/store.js --- a/src/host/store.js +++ b/src/host/store.js -@@ -1,21 +1,53 @@ +@@ -1,21 +1,54 @@ // Store: the saved team (state.json), its migrations, the lock that keeps a second dsh // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. -import { mkdirSync, statSync } from 'node:fs' +import { install as installProjects } from './project-service.js' ++import { encodeProjectState, decodeProjectState } from './project-state-format.js' +import { createProjectStateStorage } from './project-state-storage.js' +import { createProjectServiceEvents } from './project-service-events.js' +import { constants, mkdirSync, statSync } from 'node:fs' @@ -862,7 +864,16 @@ diff --git a/src/host/store.js b/src/host/store.js let contents try { contents = await readFile(statePath, 'utf8') -@@ -87,13 +87,59 @@ export function install(rt) { +@@ -73,7 +73,7 @@ export function install(rt) { + seen = stamp() + let parsed + try { +- parsed = JSON.parse(contents) ++ parsed = decodeProjectState(JSON.parse(contents)) + } catch (error) { + const backup = `${statePath}.broken-${Date.now()}` + try { +@@ -87,13 +87,64 @@ export function install(rt) { return undefined } if (parsed?.version === 1) { @@ -878,26 +889,31 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: unreadable state %s: %s', statePath, backupProblem) + return undefined + } -+ stuck = undefined -+ return parsed -+ } + stuck = undefined + return parsed + } +- stuck = 'saved by a newer DS Bot; update DS Bot' + if (parsed?.version === STATE_VERSION) { ++ if (parsed.projectCompatibility && !rt.sharedProjectMode?.()) { ++ stuck = 'Project compatibility state requires projectWorkbench or controlPlane; preserve the shared access boundary' ++ return undefined ++ } + const problem = ownershipProblem(parsed) + if (problem !== undefined) { + stuck = `malformed ownership: ${problem}` + warn('ds-bot: state %s was refused: %s', statePath, problem) + return undefined + } - stuck = undefined - return parsed - } -- stuck = 'saved by a newer DS Bot; update DS Bot' ++ stuck = undefined ++ return parsed ++ } + stuck = 'saved by a newer FlyBot; update FlyBot' return undefined } + const validateSqliteState = parsed => { + if (parsed?.version !== STATE_VERSION) throw new Error('Unsupported SQLite state version') ++ if (parsed.projectCompatibility && !rt.sharedProjectMode?.()) throw new Error('Project compatibility state requires projectWorkbench or controlPlane') + const problem = ownershipProblem(parsed) + if (problem !== undefined) throw new Error(`Malformed SQLite ownership: ${problem}`) + } @@ -968,7 +984,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,18 +161,102 @@ export function install(rt) { +@@ -161,18 +161,103 @@ export function install(rt) { migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -1027,6 +1043,7 @@ diff --git a/src/host/store.js b/src/host/store.js + if (rt.projectScheduledTasks?.recover()) migrated = true + if (rt.projectChiefControl?.recover()) migrated = true + if (rt.projectEffects?.recover()) migrated = true ++ if (rt.projectCompatibility?.recover()) migrated = true + if (syncDesignatedChief()) migrated = true indexSessions() + committedEvents.load(state) @@ -1168,7 +1185,7 @@ diff --git a/src/host/store.js b/src/host/store.js + const persistedSnapshot = JSON.stringify(prepared.snapshot, null, 2) + if (storage.sqlite) storage.write(persistedSnapshot) + else { -+ await writeFile(temp, persistedSnapshot, 'utf8') ++ await writeFile(temp, JSON.stringify(encodeProjectState(prepared.snapshot), null, 2), 'utf8') + ensureLock() + await rename(temp, statePath) + } @@ -1177,7 +1194,7 @@ diff --git a/src/host/store.js b/src/host/store.js + state.projectServiceEventStreams = structuredClone(prepared.snapshot.projectServiceEventStreams) + state.projectServiceEvents = structuredClone(prepared.snapshot.projectServiceEvents) seen = stamp() -+ await journal?.record(persistedSnapshot) ++ await journal?.record(JSON.stringify(encodeProjectState(prepared.snapshot), null, 2)) rt.capabilitiesStateSaved?.() } catch (error) { - await unlink(temp).catch(() => {}) diff --git a/patches/ds-bot/files/src/host/project-access.js b/patches/ds-bot/files/src/host/project-access.js index 3cfc9aa4..95e3a902 100644 --- a/patches/ds-bot/files/src/host/project-access.js +++ b/patches/ds-bot/files/src/host/project-access.js @@ -59,7 +59,7 @@ export function install(rt) { queueDepth: queue.length, messages: [], preview: null } }) } } - async function workflow(body, actor) { + async function workflow(body, actor, controllerAttempt) { const id = String(body.projectId ?? '') const operation = body.operation ?? 'state' const actions = { state: 'read', templates: 'read', create: 'configure', graph: 'configure', concurrency: 'configure', @@ -81,6 +81,9 @@ export function install(rt) { if (!task || task.projectId !== id) throw refusal('A Project Run needs a Task from this Project') rt.requireProjectAccess(actor, id, 'task-write', task) rt.projectTaskLifecycle?.requireExecutable(task) + if (task.projectTask && (!controllerAttempt || ownEntry(state.projectAttempts ?? {}, controllerAttempt.id) !== controllerAttempt + || controllerAttempt.taskId !== task.id || controllerAttempt.projectId !== id || controllerAttempt.workflowId !== item.id + || task.deliveryAttemptId !== controllerAttempt.id || controllerAttempt.status !== 'running')) throw refusal('Use task.run so the Project Task controller owns execution and Handoff') payload = { workflowId: item.id, taskId: task.id, title: task.title, brief: task.note ?? task.title, projectId: id, callerBotId: project.chief?.botId } const existing = activeRuns(id).find(run => run.taskId === task.id) if (existing) { @@ -110,6 +113,8 @@ export function install(rt) { workflowTail = result.then(() => {}, () => {}) return result } + rt.assignProjectDeliveryWorkflow = (attempt, actor) => workflow({ projectId: attempt.projectId, operation: 'assign', workflowId: attempt.workflowId, taskId: attempt.taskId }, actor, attempt) + rt.projectAccessEndpoint = async (endpoint, body = {}, actor) => { if (!rt.sharedProjectMode()) return undefined if (!actor?.memberId || !ownEntry(state.members, actor.memberId)) throw refusal('Sign in first') diff --git a/patches/ds-bot/files/src/host/project-compatibility.js b/patches/ds-bot/files/src/host/project-compatibility.js new file mode 100644 index 00000000..3c3ca20a --- /dev/null +++ b/patches/ds-bot/files/src/host/project-compatibility.js @@ -0,0 +1,65 @@ +// Keep original records authoritative; only pre-cutover Project work may use legacy drivers. +import { createHash, randomUUID } from 'node:crypto' +import { ownEntry } from './store.js' + +const VERSION = 'project-compatibility/v1' +const fail = message => Object.assign(new Error(message), { code: 'bot/compatibility' }) +const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const identity = task => ({ taskId: task.id, projectId: task.projectId, requesterId: task.requesterId ?? null, createdAt: task.createdAt ?? null }) +const active = task => !['accepted', 'cancelled', 'failed'].includes(task.status) + +export function install(rt) { + rt.projectEffects.register('compat.cutover', { run: row => ({ cutoverId: row.payload.cutoverId, count: row.payload.count, sha256: row.payload.sha256 }) }) + function recover() { + if (!rt.sharedProjectMode?.() || rt.readOnly?.() !== undefined || rt.state.projectCompatibility) return false + const cutoverId = randomUUID(), projects = {} + for (const project of Object.values(rt.state.projects ?? {})) { + const tasks = Object.values(rt.state.tasks ?? {}).filter(task => task.projectId === project.id && !task.projectTask).sort((a, b) => a.id.localeCompare(b.id)) + const sha256 = hash(tasks.map(identity)) + for (const task of tasks) task.legacyProject = { version: VERSION, cutoverId, ...identity(task) } + const effect = rt.projectEffects.stage({ kind: 'compat.cutover', projectId: project.id, key: cutoverId, payload: { cutoverId, count: tasks.length, sha256 } }) + projects[project.id] = { effect, count: tasks.length, sha256 } + } + rt.state.projectCompatibility = { version: VERSION, cutoverId, createdAt: Date.now(), projects } + return true + } + function committed(projectId, cutover, project) { + const effect = ownEntry(rt.state.projectEffects ?? {}, project?.effect?.id) + return Boolean(effect?.kind === 'compat.cutover' && effect.projectId === projectId && effect.payload.cutoverId === cutover?.cutoverId + && effect.payload.count === project.count && effect.payload.sha256 === project.sha256 && rt.projectEffects.intentCommitted(project.effect)) + } + function legacyAllowed(task) { + if (task.projectTask) return false + if (!task.projectId || !rt.sharedProjectMode?.() && !rt.state.projectCompatibility) return true + const cutover = rt.state.projectCompatibility, marker = task.legacyProject + const project = ownEntry(cutover?.projects ?? {}, task.projectId) + return Boolean(cutover?.version === VERSION && marker?.version === VERSION && marker.cutoverId === cutover.cutoverId + && hash(identity(task)) === hash({ taskId: marker.taskId, projectId: marker.projectId, requesterId: marker.requesterId, createdAt: marker.createdAt }) + && project && committed(task.projectId, cutover, project)) + } + function requireLegacy(task) { + if (!legacyAllowed(task)) throw fail('Project Task contract requires Chief admission; unknown post-cutover legacy work cannot execute') + } + function registerContinuation(original, child) { + requireLegacy(original) + if (!original.projectId || !rt.state.projectCompatibility) return + if (child.reworkOf !== original.id || child.projectId !== original.projectId || child.requesterId !== original.requesterId) throw fail('Legacy continuation identity changed') + child.legacyProject = { version: VERSION, cutoverId: rt.state.projectCompatibility.cutoverId, ...identity(child), derivedFrom: original.id } + } + function report(projectId, body, actor) { + rt.requireProjectAccess(actor, projectId) + const limit = body.limit ?? 50, offset = body.offset ?? 0 + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) throw fail('Invalid compatibility page') + const tasks = Object.values(rt.state.tasks ?? {}).filter(task => task.projectId === projectId).sort((a, b) => a.id.localeCompare(b.id)) + const classify = task => task.projectTask ? 'contract' : legacyAllowed(task) ? 'legacy' : 'held' + const totals = { contract: 0, legacy: 0, held: 0, active: 0 } + for (const task of tasks) { ++totals[classify(task)]; if (active(task)) ++totals.active } + const cutover = rt.state.projectCompatibility, proof = ownEntry(cutover?.projects ?? {}, projectId) + return { version: VERSION, cutoverId: cutover?.cutoverId ?? null, committed: committed(projectId, cutover, proof), + baseline: proof ? { count: proof.count, sha256: proof.sha256 } : null, totals, + tasks: tasks.slice(offset, offset + limit).map(task => ({ id: task.id, projectId, parentId: task.parentId ?? null, status: task.status, mode: classify(task), + attemptId: task.deliveryAttemptId ?? null, parentPresent: !task.parentId || ownEntry(rt.state.tasks ?? {}, task.parentId)?.projectId === projectId })), + page: { offset, limit, total: tasks.length, nextOffset: offset + limit < tasks.length ? offset + limit : null } } + } + rt.projectCompatibility = { recover, legacyAllowed, requireLegacy, registerContinuation, report } +} diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index 2b508229..b767a068 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -307,7 +307,9 @@ export function createProjectDelivery(rt, { publisher } = {}) { attempt.status = 'running' if (rt.taskDeliveryState?.(task.id, attempt.id, 'working', 'Delivery started') === false) throw reject('Task ended before delivery started') await rt.save() - const assigned = await rt.projectEndpoint('project-workflow', { projectId: project.id, operation: 'assign', workflowId: workflow.id, taskId: task.id }, actor) + const assigned = task.projectTask + ? await rt.assignProjectDeliveryWorkflow(attempt, actor) + : await rt.projectEndpoint('project-workflow', { projectId: project.id, operation: 'assign', workflowId: workflow.id, taskId: task.id }, actor) if (assigned.ok === false) throw reject(assigned.error) const run = assigned.workflows.find(row => row.id === workflow.id)?.run attempt.runId = run?.id ?? null diff --git a/patches/ds-bot/files/src/host/project-development-service.js b/patches/ds-bot/files/src/host/project-development-service.js index 9a8c29ca..464de165 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -31,7 +31,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -219,7 +219,7 @@ export function createDevelopmentService(rt) { if (!record(body) || body.version !== DEVELOPMENT_PROTOCOL || !['command', 'query'].includes(body.kind) || !record(body.payload ?? {})) throw failure('Unsupported Project development request') const { name, projectId } = body rt.requireProjectAccess(actor, projectId) - if (!Object.hasOwn(operations, name) && !['tasks', 'task', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'attempt.context', 'effects'].includes(name)) throw failure('Unknown Project development operation') + if (!Object.hasOwn(operations, name) && !['compatibility', 'tasks', 'task', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'attempt.context', 'effects'].includes(name)) throw failure('Unknown Project development operation') if (queries.has(name) !== (body.kind === 'query')) throw failure('Operation kind does not match the contract') if (body.kind === 'command' && !stableId(body.requestId)) throw failure('A command needs a stable requestId') const args = { ...(body.payload ?? {}), projectId, ...(body.requestId !== undefined ? { requestId: body.requestId } : {}) } @@ -255,6 +255,7 @@ export function createDevelopmentService(rt) { let result if (name === 'command.receipt') { if (!receipts) throw failure('Command receipts are unavailable'); result = receipts.get(projectId, args.commandRequestId, actor, args.commandName) } else if (name === 'command.result') { if (!receipts) throw failure('Command receipts are unavailable'); result = receipts.result(projectId, args, actor) } + else if (name === 'compatibility') result = rt.projectCompatibility.report(projectId, args, actor) else if (name === 'events') result = rt.committedProjectEvents.query(projectId, args) else if (name === 'project.snapshot') result = rt.committedProjectEvents.snapshot(projectId, args) else if (name === 'effects') result = { effects: rt.projectEffects.list(projectId, actor, args) } diff --git a/patches/ds-bot/files/src/host/project-state-format.js b/patches/ds-bot/files/src/host/project-state-format.js new file mode 100644 index 00000000..1292cf1f --- /dev/null +++ b/patches/ds-bot/files/src/host/project-state-format.js @@ -0,0 +1,11 @@ +// A physical format fence prevents old JSON binaries from replaying new contracts. +const FORMAT = 'flybot-project-state/v1' +export function encodeProjectState(state) { + if (!state.projectCompatibility && !Object.values(state.tasks ?? {}).some(task => task.projectTask)) return state + return { ...state, version: 4, stateFormat: FORMAT, logicalVersion: 2 } +} +export function decodeProjectState(state) { + if (state?.version !== 4 || state.stateFormat !== FORMAT || state.logicalVersion !== 2) return state + const { stateFormat: _format, logicalVersion: _version, ...logical } = state + return { ...logical, version: 2 } +} diff --git a/patches/ds-bot/files/src/host/project-state-migration.js b/patches/ds-bot/files/src/host/project-state-migration.js index 775aa375..77ca1c9d 100644 --- a/patches/ds-bot/files/src/host/project-state-migration.js +++ b/patches/ds-bot/files/src/host/project-state-migration.js @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { existsSync, readFileSync, renameSync } from 'node:fs' import { join } from 'node:path' +import { encodeProjectState, decodeProjectState } from './project-state-format.js' import { homeLock } from './home-lock.js' import { openSqliteState } from './project-state-sqlite.js' import { writeDurableStateFile } from './project-state-storage.js' @@ -29,7 +30,7 @@ function snapshot(home, expectedMigrationId) { const state = db.read() if (state.version !== 2) throw fail('Unsupported exported state version') guardOf(home, expectedMigrationId) - return { text: JSON.stringify(state, null, 2), revision: metadata.revision } + return { text: JSON.stringify(encodeProjectState(state), null, 2), revision: metadata.revision } } finally { db.close() } } export function exportSqliteState({ home, output, expectedMigrationId }) { @@ -59,7 +60,7 @@ export function rollbackSqliteState({ home, expectedMigrationId, resume = false } if (marker.version !== 'flybot-storage-rollback/v1' || !/^[a-f0-9-]{36}$/.test(marker.id) || marker.archiveFile !== `state.sqlite.rollback-${marker.id}` || marker.exportFile !== `state.rollback-${marker.id}.json` || !/^[a-f0-9]{64}$/.test(marker.sha256)) throw fail('Rollback recovery marker is malformed') const text = readFileSync(join(home, marker.exportFile), 'utf8') - if (digest(text) !== marker.sha256 || JSON.parse(text).version !== 2) throw fail('Rollback export hash changed') + if (digest(text) !== marker.sha256 || decodeProjectState(JSON.parse(text)).version !== 2) throw fail('Rollback export hash changed') const currentText = readFileSync(statePath, 'utf8') if (digest(currentText) !== marker.sha256) { guardOf(home, expectedMigrationId) diff --git a/patches/ds-bot/files/src/host/project-state-storage.js b/patches/ds-bot/files/src/host/project-state-storage.js index fd9b4369..e48700da 100644 --- a/patches/ds-bot/files/src/host/project-state-storage.js +++ b/patches/ds-bot/files/src/host/project-state-storage.js @@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto' import { constants, closeSync, existsSync, fsyncSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { encodeProjectState, decodeProjectState } from './project-state-format.js' import { openSqliteState, requireSqliteRuntime } from './project-state-sqlite.js' const digest = value => createHash('sha256').update(value).digest('hex') @@ -54,7 +55,7 @@ export function createProjectStateStorage({ home, statePath = join(home, 'state. let text try { text = readFileSync(fd, 'utf8') } finally { closeSync(fd) } if (digest(text) !== proof.sha256) throw fail('SQLite import backup hash changed') - const state = JSON.parse(text) + const state = decodeProjectState(JSON.parse(text)) validate(state) return state } @@ -104,7 +105,7 @@ export function createProjectStateStorage({ home, statePath = join(home, 'state. ensureWriter(); validate(state); requireSqliteRuntime() guard = readGuard() if (!guard) { - const text = JSON.stringify(state), sha256 = digest(text), backup = `state.sqlite-import-${sha256}.json` + const text = JSON.stringify(encodeProjectState(state)), sha256 = digest(text), backup = `state.sqlite-import-${sha256}.json` writeDurableStateFile(join(home, backup), text, { exclusive: true }) guard = { version: 3, storage: 'sqlite', phase: 'prepared', migration: { id: randomUUID(), backup, sha256, importedAt: Date.now() } } // Guard first: a crash before SQL initialization remains recoverable from diff --git a/patches/ds-bot/files/src/host/project-task-lifecycle.js b/patches/ds-bot/files/src/host/project-task-lifecycle.js index 0bbb2d15..6522d3ab 100644 --- a/patches/ds-bot/files/src/host/project-task-lifecycle.js +++ b/patches/ds-bot/files/src/host/project-task-lifecycle.js @@ -195,7 +195,7 @@ export function createProjectTaskLifecycle(rt) { return task } function requireExecutable(task) { - if (!task.projectTask) return // Historical Tasks keep their original admission record. + if (!task.projectTask) { rt.projectCompatibility?.requireLegacy(task); return } const row = contract(task) if (row.admission.status !== 'accepted') throw fail('Project Task requires Chief admission') if (row.wait && ['waiting', 'ready'].includes(row.wait.status)) throw fail('Project Task is waiting for child results') diff --git a/patches/ds-bot/files/src/host/task-drive.js b/patches/ds-bot/files/src/host/task-drive.js index ffa4a4d1..6b0f7c7e 100644 --- a/patches/ds-bot/files/src/host/task-drive.js +++ b/patches/ds-bot/files/src/host/task-drive.js @@ -95,7 +95,7 @@ export function install(rt) { // they had: retiring stops the delivery, it invents no new state. if (retiredBot(bot)) return [] return Object.values(rt.state.tasks ?? {}).filter((task) => { - if (task.projectTask || task.ownerBotId !== botId || task.waitingFor === 'input') return false + if (task.projectTask || rt.projectCompatibility && !rt.projectCompatibility.legacyAllowed(task) || task.ownerBotId !== botId || task.waitingFor === 'input') return false // An item with a role and no claimer belongs to the room, not to this Bot: the enqueuer // must not be woken for its own item (#1247). A claimed one names the member and is // driven above like any other card. @@ -299,7 +299,7 @@ export function install(rt) { async function offerQueue() { const byRoomRole = new Map() for (const task of Object.values(rt.state.tasks ?? {})) { - if (task.projectTask || !task?.queueRole || task.claimedBy || task.status !== 'open') continue + if (task.projectTask || rt.projectCompatibility && !rt.projectCompatibility.legacyAllowed(task) || !task?.queueRole || task.claimedBy || task.status !== 'open') continue const roomId = task.origin?.roomId const room = roomId ? rt.roomOf(roomId) : undefined if (roomId && (!room || room.origin === 'face-to-face')) continue diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 3c3fe7f1..84f3749c 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -273,6 +273,7 @@ export function install(rt) { const projectTransitions = new WeakSet() function transition(task, to, actor, extra) { if (task.projectTask && !projectTransitions.has(task)) throw forbidden('Use the Project Task lifecycle; Chief owns admission and final judgement') + if (!task.projectTask && !projectTransitions.has(task)) rt.projectCompatibility?.requireLegacy(task) if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`) if (waitingForInput(task) && to !== 'blocked') { delete task.waitingFor @@ -523,6 +524,7 @@ export function install(rt) { // endpoints all reach it, so a refusal is the same refusal wherever the value came from. The // reads happen before any assignment, so a refused write leaves the card exactly as it was. function writeContract(task, body = {}) { + if (task.projectTask && ['taskClass', 'reworkOf', 'handoff', 'lease', 'evidence'].some(key => body[key] !== undefined)) throw forbidden('Use the Project Task and Handoff contracts to change execution or evidence') const taskClass = readTaskClass(body.taskClass) const reworkOf = readReworkOf(body.reworkOf) const handoff = readHandoff(body.handoff) @@ -673,6 +675,7 @@ export function install(rt) { function expireLeases(now = Date.now()) { let expired = 0 for (const task of Object.values(store())) { + if (rt.projectCompatibility && !rt.projectCompatibility.legacyAllowed(task)) continue const lease = leaseOf(task) if (lease === undefined || !Number.isFinite(lease.expiresAt) || lease.expiresAt > now) continue // A card waiting for its rework is not abandoned work (#1804): the open child card is the @@ -707,6 +710,7 @@ export function install(rt) { // means "part of an overall card" and must never make `settleOverall` fire. A replay of the // same rejection reuses the child that is still open instead of creating a second one. function openRework(original, note, by = 'requester') { + rt.projectCompatibility?.requireLegacy(original) if (original.projectId) rt.projects?.requireActive(original.projectId) const reason = boundedLine(note, HANDOFF_REASON_MAX, 'handoff.reason') // A worker-side return names the role that produced the card (#1775); the requester's own @@ -743,6 +747,7 @@ export function install(rt) { actors: [{ status: 'open', at, by: 'rework opened' }], } rt.projects?.stampTask(child) + rt.projectCompatibility?.registerContinuation(original, child) store()[child.id] = child return child } @@ -849,6 +854,7 @@ export function install(rt) { // blocked → open is the queue's own edge: a member that cannot finish returns the item, and // no other path sends a card back to the queue, so the edge is not on TRANSITIONS. function requeue(task, reason, retryInSec, by = 'returned to the queue') { + rt.projectCompatibility?.requireLegacy(task) dropWorker(task.worker) delete task.worker delete task.waitingFor @@ -949,6 +955,7 @@ export function install(rt) { ? undefined : open.filter(task => cardNamesIssue(task, issue)).sort(newestFirst)[0] if (held) { + rt.projectCompatibility?.requireLegacy(held) held.note = text // Both paths below append to `actors`, and `record()` pushes without a guard, so a card // stored before the field existed would throw after the note moved and before the save @@ -985,6 +992,7 @@ export function install(rt) { } defaultContract(task) rt.projects?.stampTask(task) + rt.projectCompatibility?.requireLegacy(task) store()[task.id] = task await save() return view(task) @@ -1034,6 +1042,7 @@ export function install(rt) { await rt.load() const live = find(pick.id) if (live === undefined || live.status !== 'open' || live.claimedBy !== undefined) return { item: null, reason: 'taken' } + rt.projectCompatibility?.requireLegacy(live) live.claimedBy = botId live.ownerBotId = botId delete live.retryAt @@ -1076,6 +1085,7 @@ export function install(rt) { await rt.load() const live = find(pick.id) if (live === undefined || live.status !== 'open' || live.claimedBy !== undefined) return { item: null, reason: 'taken' } + rt.projectCompatibility?.requireLegacy(live) live.claimedBy = botId live.ownerBotId = botId delete live.retryAt @@ -1162,6 +1172,7 @@ export function install(rt) { } defaultContract(task) rt.projects?.stampTask(task) + rt.projectCompatibility?.requireLegacy(task) store()[task.id] = task return task } @@ -1241,6 +1252,7 @@ export function install(rt) { task = makeTask(bot, title, roomId, parent) } } + rt.projectCompatibility?.requireLegacy(task) if (!frozen) { if (parent && task.kind !== 'group') { task.parentId = parent.id @@ -1398,6 +1410,7 @@ export function install(rt) { // A plugin may claim an open card for a local Bot (#1318). The second save // does not ask again, so a listener cannot re-enter this write. async function routeOpenTask(task, kind) { + if (task.projectTask) return if (typeof rt.onTaskWrite !== 'function') return let route try { @@ -1547,6 +1560,7 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) if (!mayUpdate(task, actor)) throw forbidden('Only the requester or the Bot\'s owner may update this task') rt.projects?.validateTask(task, body) + if (!task.projectTask) rt.projectCompatibility?.requireLegacy(task) if (task.projectTask && ['title', 'criteria', 'impact', 'scope'].some(key => body[key] !== undefined && body[key] !== (key === 'title' || key === 'criteria' ? task[key] : task.projectTask.requirements[key]))) throw forbidden('Project requirements changes need Chief confirmation') writeContract(task, body) const { progress } = applyFields(task, body) diff --git a/patches/ds-bot/files/src/host/workbench-personal.js b/patches/ds-bot/files/src/host/workbench-personal.js index 14a196f4..35a3d321 100644 --- a/patches/ds-bot/files/src/host/workbench-personal.js +++ b/patches/ds-bot/files/src/host/workbench-personal.js @@ -7,8 +7,11 @@ const personalBot = bot => bot && !bot.projectAgent && !bot.ephemeral && !bot.pr export function personalRoster(rt, actor) { if (!actor?.memberId) throw refused() const bots = Object.values(rt.state.bots).filter(bot => personalBot(bot) && rt.botOwnedBy(actor, bot.id)) - const rooms = Object.values(rt.state.rooms).filter(room => !room.projectId && !room.face && (rt.roomMemberOf(actor, room.id) || rt.roomOwnedBy(actor, room.id)) && (room.members ?? []).every(id => personalBot(ownEntry(rt.state.bots, id)))) - return { bots: bots.map(({ id, name, role, instructions, model, color, avatar, parentId, sessionId }) => ({ id, name, role, instructions, model, color, avatar, parentId, sessionId: rt.chatOf?.(id) ?? sessionId ?? id, parts: (rt.partsOf?.(id) ?? []).map(part => ({ sessionId: part.sessionId, number: part.number })) })), rooms: rooms.map(({ id, name, members, participants, mode }) => ({ id, name, members, participants: (participants ?? []).map(({ id, kind, displayName, role, joinedSeq, leftSeq }) => ({ id, kind, displayName, role, joinedSeq, leftSeq })), mode })), mainBotIds: rt.state.mainBotIds.filter(id => bots.some(bot => bot.id === id)) } + const rooms = Object.values(rt.state.rooms).filter(room => (!room.projectId || rt.projectCan?.(actor, room.projectId, 'read')) && !room.face && (rt.roomMemberOf(actor, room.id) || rt.roomOwnedBy(actor, room.id)) && (room.members ?? []).every(id => { + const bot = ownEntry(rt.state.bots, id) + return bot && !bot.projectAgent && !bot.ephemeral && !bot.remote && (!bot.projectId || bot.projectId === room.projectId) + })) + return { bots: bots.map(({ id, name, role, instructions, model, color, avatar, parentId, sessionId }) => ({ id, name, role, instructions, model, color, avatar, parentId, sessionId: rt.chatOf?.(id) ?? sessionId ?? id, parts: (rt.partsOf?.(id) ?? []).map(part => ({ sessionId: part.sessionId, number: part.number })) })), rooms: rooms.map(({ id, name, members, participants, mode, projectId }) => ({ id, name, ...(projectId ? { projectId } : {}), members, participants: (participants ?? []).map(({ id, kind, displayName, role, joinedSeq, leftSeq }) => ({ id, kind, displayName, role, joinedSeq, leftSeq })), mode })), mainBotIds: rt.state.mainBotIds.filter(id => bots.some(bot => bot.id === id)) } } export function requirePersonalSession(rt, actor, id) { const roster = personalRoster(rt, actor) diff --git a/patches/ds-bot/files/test/project-access.test.js b/patches/ds-bot/files/test/project-access.test.js index 6303d2ef..e80c7862 100644 --- a/patches/ds-bot/files/test/project-access.test.js +++ b/patches/ds-bot/files/test/project-access.test.js @@ -11,13 +11,13 @@ import { fakeContext } from './fake-ctx.js' const alice = { memberId: 'owner-1' } const bob = { memberId: 'bob' } const outsider = { memberId: 'outsider' } -async function setup(t) { +async function setup(t, tasks = {}) { const home = mkdtempSync(join(tmpdir(), 'flybot-access-')) const state = { version: 2, mainBotIds: ['a'], members: { 'owner-1': { id: 'owner-1' }, bob: { id: 'bob' }, outsider: { id: 'outsider' } }, bots: { a: { id: 'a', name: 'Chief', ownerId: 'owner-1', role: 'Owner', sessionId: 'a', projectId: 'p1', createdAt: 1 } }, projects: { p1: { id: 'p1', name: 'Shared', ownerUserId: 'owner-1', status: 'active', createdAt: 1, updatedAt: 1, roles: { maintainers: [], members: ['bob'] } }, p2: { id: 'p2', name: 'Private', ownerUserId: 'outsider', status: 'active', createdAt: 1, updatedAt: 1 } }, - rooms: {}, tasks: {}, projectRuns: {}, exchanges: [], exchangeTurns: {}, agents: {} } + rooms: {}, tasks, projectRuns: {}, exchanges: [], exchangeTurns: {}, agents: {} } writeFileSync(join(home, 'state.json'), JSON.stringify(state)) const ctx = fakeContext() Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [] }, @@ -96,7 +96,9 @@ test('explicit null cannot detach a new shared Task from membership revocation', }) test('Workflow gateway isolates projects, serializes quota admission and makes duplicate starts harmless', async t => { - const rt = await setup(t) + // Compatibility graphs continue only pre-cutover work; new Tasks must use the controller. + const legacy = id => ({ id, projectId: 'p1', requesterId: 'bob', ownerBotId: 'a', title: id, criteria: 'Result verified', status: 'open', actors: [], createdAt: 1 }) + const rt = await setup(t, { first: legacy('first'), second: legacy('second') }) const service = createWorkflowService({ persist: false, readProject: id => rt.readProject(id), projectContext: (taskId, projectId) => rt.flybot.projects.context(taskId, projectId), bindRun: (id, projectId, details) => rt.flybot.projects.bindRun(id, projectId, details) }) @@ -114,13 +116,14 @@ test('Workflow gateway isolates projects, serializes quota admission and makes d const snapshot = await rt.projectEndpoint('project-workflow', { projectId: 'p1' }, bob) assert.deepEqual(snapshot.workflows.map(row => row.name), ['Shared graph']) await assert.rejects(create('p1', bob, 'Forbidden'), /access denied/) - const first = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p1', title: 'First', criteria: 'First result verified', impact: 'First Project change' }, bob) - const second = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p1', title: 'Second', criteria: 'Second result verified', impact: 'Second Project change' }, bob) + const first = rt.state.tasks.first, second = rt.state.tasks.second + const canonical = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p1', title: 'New work', criteria: 'New result verified', impact: 'New Project change' }, bob) await rt.projectEndpoint('project-quota', { id: 'p1', maxConcurrent: 1 }, alice) const start = taskId => rt.projectEndpoint('project-workflow', { projectId: 'p1', operation: 'assign', workflowId: graph.id, taskId }, bob) - await assert.rejects(start(first.id), /Chief admission/) + await assert.rejects(start(canonical.id), /Chief admission/) const { admitTask } = await import('../src/host/project-task-lifecycle.js') - for (const task of [first, second]) admitTask(rt.state.tasks[task.id], { id: 'admit-' + task.id, taskId: task.id, projectId: 'p1', executorRole: 'chief', runId: 'chief-fixture', initiatedBy: alice.memberId, reason: 'Scoped requirements fixture' }) + admitTask(rt.state.tasks[canonical.id], { id: 'admit-' + canonical.id, taskId: canonical.id, projectId: 'p1', executorRole: 'chief', runId: 'chief-fixture', initiatedBy: alice.memberId, reason: 'Scoped requirements fixture' }) + await assert.rejects(start(canonical.id), /task.run|controller/) rt.state.projectExecutions['chief-job'] = { id: 'chief-job', projectId: 'p1', kind: 'chief-control', status: 'running' } const results = await Promise.allSettled([start(first.id), start(second.id)]) assert.equal(results.filter(row => row.status === 'fulfilled').length, 1) diff --git a/patches/ds-bot/files/test/project-compatibility.test.js b/patches/ds-bot/files/test/project-compatibility.test.js new file mode 100644 index 00000000..87a79986 --- /dev/null +++ b/patches/ds-bot/files/test/project-compatibility.test.js @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const actor = { memberId: 'owner-1' } +const seed = () => ({ version: 2, revision: 1, members: { 'owner-1': { id: 'owner-1', designatedChiefId: 'a' } }, mainBotIds: ['a'], + bots: { a: { id: 'a', ownerId: 'owner-1', sessionId: 'a', name: 'Chief', role: 'Chief' } }, + projects: { p: { id: 'p', name: 'Project', ownerUserId: 'owner-1', status: 'active', roles: { members: [], maintainers: [] } } }, + rooms: { g: { id: 'g', name: 'Group', admin: 'a', members: ['a'], sessions: {}, log: [], seq: 0, seen: {}, projectId: 'p' } }, + tasks: { old: { id: 'old', title: 'Existing work', projectId: 'p', ownerBotId: 'a', requesterId: 'owner-1', status: 'working', source: 'conversation', createdAt: 1, actors: [{ status: 'working', at: 1 }], origin: { roomId: 'g' } } } }) +async function fixture(t, state = seed()) { + const home = mkdtempSync(join(tmpdir(), 'flybot-compat-')), path = join(home, 'state.json') + writeFileSync(path, JSON.stringify(state)) + const hosts = [] + async function start(config = {}) { + const ctx = fakeContext() + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = options => { if (options.path.endsWith('bot')) route = options; return register(options) } + Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [] }, workspaceRegistry: { get: () => undefined, archiveSession: async () => {} }, + sessionController: { resolveAgent: async () => ({ agent: undefined }) }, sessions: { flush: async () => {} } }) + const rt = createRuntime(ctx, { home, projectWorkbench: true, ...config }) + let closed = false + const close = async () => { if (!closed) { closed = true; await ctx.dispose() } } + hosts.push(close); await rt.load() + return { rt, ctx, close, route } + } + t.after(async () => { for (const close of hosts.reverse()) await close(); rmSync(home, { recursive: true, force: true }) }) + return { home, path, start } +} + +test('shared group dispatch cannot create an unadmitted legacy Project Task', async t => { + const f = await fixture(t), { rt } = await f.start() + await assert.rejects(rt.assignFromDispatch({ roomId: 'g', botId: 'a', title: 'Change code', note: 'Implement a change', issue: 8 }), /Chief|Task.*contract/) + assert.deepEqual(Object.keys(rt.state.tasks), ['old']) +}) + +test('Bot tracking cannot create Project cards but personal tracking remains available', async t => { + const f = await fixture(t), { rt } = await f.start() + await assert.rejects(rt.trackBotTasks('a', { tasks: [{ title: 'Change code' }] }, { roomId: 'g' }), /Chief|Task.*contract/) + const personal = await rt.trackBotTasks('a', { tasks: [{ title: 'My reminder' }] }, {}) + assert.ok(personal) + const reminder = Object.values(rt.state.tasks).find(row => row.title === 'My reminder') + assert.equal(reminder.projectId, null) +}) + +test('cutover preserves identities/history, reloads once and refuses unknown later legacy work', async t => { + const f = await fixture(t), first = await f.start() + const old = first.rt.state.tasks.old + first.rt.projectTaskLifecycle.requireExecutable(old) + const marker = structuredClone(old.legacyProject) + assert.ok(marker?.cutoverId) + assert.deepEqual(old.actors, seed().tasks.old.actors) + first.rt.state.tasks.later = { ...structuredClone(old), id: 'later' } + assert.throws(() => first.rt.projectTaskLifecycle.requireExecutable(first.rt.state.tasks.later), /cutover|legacy|Chief/) + await first.rt.save(); await first.close() + const second = await f.start() + assert.deepEqual(second.rt.state.tasks.old.legacyProject, marker) + second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks.old) + assert.throws(() => second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks.later), /cutover|legacy|Chief/) + assert.equal(second.rt.state.members['owner-1'].designatedChiefId, 'a') + assert.equal(second.rt.state.bots.a.sessionId, 'a') + assert.equal(second.rt.state.rooms.g.projectId, 'p') + assert.equal(second.rt.state.tasks.old.requesterId, 'owner-1') +}) + +test('cutover state fences old JSON-only binaries while retaining logical Store version', async t => { + const f = await fixture(t), { rt } = await f.start() + await rt.save() + const disk = JSON.parse(readFileSync(f.path, 'utf8')) + assert.equal(disk.version, 4) + assert.equal(disk.stateFormat, 'flybot-project-state/v1') + assert.equal(rt.state.version, 2) +}) + +test('Project compatibility report is scoped, paginated and distinguishes unknown work without exposing content', async t => { + const state = seed() + state.tasks.child = { ...structuredClone(state.tasks.old), id: 'child', parentId: 'old', note: 'Private task material' } + state.tasks.personal = { ...structuredClone(state.tasks.old), id: 'personal', projectId: null } + const f = await fixture(t, state), { rt } = await f.start() + rt.state.tasks.unknown = { ...structuredClone(rt.state.tasks.old), id: 'unknown' } + const report = await rt.projectEndpoint('project-development', { version: 'project-development/v1', kind: 'query', name: 'compatibility', projectId: 'p', payload: { limit: 1 } }, actor) + assert.equal(report.result.totals.legacy, 2) + assert.equal(report.result.totals.held, 1) + assert.equal(report.result.baseline.count, 2) + assert.equal(report.result.committed, true) + assert.equal(report.result.tasks[0].id, 'child') + assert.equal(report.result.tasks[0].parentPresent, true) + assert.equal(report.result.page.nextOffset, 1) + assert.doesNotMatch(JSON.stringify(report), /Private task material|personal/) + assert.throws(() => rt.projectCompatibility.report('p', {}, { memberId: 'outsider' }), /Project|permission/) + assert.throws(() => rt.projectCompatibility.report('p', { limit: 101 }, actor), /page/) +}) + +test('an unconfirmed cutover cannot authorize legacy execution', async t => { + const f = await fixture(t), { rt } = await f.start() + const old = rt.state.tasks.old, proof = rt.state.projectCompatibility.projects.p + const original = proof.effect + proof.effect = { id: 'not-saved', generation: 1 } + assert.throws(() => rt.projectTaskLifecycle.requireExecutable(old), /cutover|legacy|Chief/) + proof.effect = original + old.requesterId = 'another-user' + assert.throws(() => rt.projectTaskLifecycle.requireExecutable(old), /cutover|legacy|Chief/) +}) + +test('cutover cannot be reopened by disabling shared authorization', async t => { + for (const stateStorage of ['json', 'sqlite']) { + const f = await fixture(t), first = await f.start({ stateStorage }) + await first.rt.save(); await first.close() + const before = readFileSync(f.path, 'utf8') + const next = await f.start({ projectWorkbench: false }) + assert.match(next.rt.readOnly(), /projectWorkbench|controlPlane/) + await next.rt.save() + assert.equal(readFileSync(f.path, 'utf8'), before) + } +}) + +test('SQLite export and rollback retain the cutover fence and do not replay committed external actions', async t => { + const { exportSqliteState, rollbackSqliteState } = await import('../src/host/project-state-migration.js') + const f = await fixture(t), first = await f.start({ stateStorage: 'sqlite' }), actionPath = join(f.home, 'external-result.txt') + let executions = 0 + first.rt.projectEffects.register('fixture.action', { run() { ++executions; writeFileSync(actionPath, 'Original committed result'); return { published: true } } }) + await first.rt.projectEffects.perform({ kind: 'fixture.action', key: 'one-action', projectId: 'p', taskId: 'old', payload: { reference: 'immutable' } }) + const migrationId = first.rt.stateStorage.status().migration.id + await first.close() + const output = join(f.home, 'export.json') + exportSqliteState({ home: f.home, output, expectedMigrationId: migrationId }) + assert.equal(JSON.parse(readFileSync(output, 'utf8')).version, 4) + rollbackSqliteState({ home: f.home, expectedMigrationId: migrationId }) + assert.equal(JSON.parse(readFileSync(f.path, 'utf8')).version, 4) + const second = await f.start({ stateStorage: 'json' }) + second.rt.projectEffects.register('fixture.action', { run() { ++executions; throw new Error('Must never replay') } }) + second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks.old) + await second.rt.projectEffects.drain() + assert.equal(executions, 1) + assert.equal(readFileSync(actionPath, 'utf8'), 'Original committed result') + assert.equal(second.rt.state.tasks.old.id, 'old') + assert.equal(second.rt.state.tasks.old.ownerBotId, 'a') +}) + +test('unknown state frames remain read-only without destroying existing records', async t => { + for (const frame of [{ version: 5 }, { version: 4, stateFormat: 'unknown', logicalVersion: 2 }, { version: 4, stateFormat: 'flybot-project-state/v1', logicalVersion: 9 }]) { + const f = await fixture(t, { ...seed(), ...frame }), before = readFileSync(f.path, 'utf8') + const { rt } = await f.start() + assert.match(rt.readOnly(), /newer FlyBot/) + await rt.save() + assert.equal(readFileSync(f.path, 'utf8'), before) + } +}) + +test('a later group binding does not retroactively assign personal Tasks or their private history', async t => { + const state = seed() + delete state.rooms.g.projectId + state.tasks.old.projectId = null + const f = await fixture(t, state), { rt } = await f.start() + await rt.projectEndpoint('project-bind', { kind: 'group', id: 'g', projectId: 'p' }, actor) + assert.equal(rt.state.tasks.old.projectId, null) + assert.equal(rt.state.tasks.old.legacyProject, undefined) + assert.equal(rt.state.bots.a.projectId, undefined) + assert.equal(rt.state.members['owner-1'].designatedChiefId, 'a') + assert.equal(rt.state.projectCompatibility.projects.p.count, 0) + assert.equal(rt.projectCompatibility.legacyAllowed(rt.state.tasks.old), true) +}) + +test('original Chief and Project-linked group use the authenticated original channel without manufacturing Tasks', async t => { + const state = seed() + state.members.bob = { id: 'bob' }; state.projects.p.roles.members.push('bob') + state.rooms.g.participants = [{ id: 'owner-1', kind: 'human', role: 'admin', joinedSeq: 0, joinedAt: 1 }, { id: 'a', kind: 'bot', role: 'admin', joinedSeq: 0, joinedAt: 1 }] + const f = await fixture(t, state), { rt, route, ctx } = await f.start() + rt.authenticate = async token => token === 'owner' ? actor : { memberId: 'bob' } + const messages = [] + rt.resolveAgent = async id => ({ session: { id }, status: 'idle', followup: message => messages.push(message), steer: message => messages.push(message) }) + rt.ensureCapabilities = async () => {} + ctx.sessionController.prompt = async input => messages.push(input) + rt.live.set('a', { turn: 1 }) + const call = async (operation, id, token = 'owner', extra = {}) => (await route.fetch(new Request('http://localhost/api/bot', { + method: 'POST', headers: { 'x-flybot-member-session': token }, body: JSON.stringify({ endpoint: 'personal-space', payload: { operation, id, ...extra } }) }))).json() + const before = Object.keys(rt.state.tasks) + assert.equal((await call('list')).value.mainBotIds[0], 'a') + assert.equal((await call('list')).value.rooms[0].projectId, 'p') + assert.equal((await call('send', 'a', 'owner', { text: 'Explain the current plan' })).ok, true) + assert.equal(messages.length, 1) + await rt.postRoomMessage('g', { text: 'A group question', displayName: 'Owner' }, actor) + assert.equal((await call('read', 'g')).value.messages.at(-1).text, 'A group question') + assert.equal((await call('read', 'g', 'bob')).ok, false, 'Project membership cannot grant private group history') + assert.deepEqual(Object.keys(rt.state.tasks), before) + rt.memoryEndpoint = async () => ({ entries: [{ scope: 'own', text: 'Private Chief memory' }, { scope: 'team', text: 'Unrelated memory' }] }) + assert.deepEqual((await call('memory', 'a')).value.entries, [{ scope: 'own', text: 'Private Chief memory' }]) + assert.equal((await call('memory', 'a', 'bob')).ok, false) + assert.equal(ctx.calls.includes('on llm/stream'), true) +}) + +test('legacy rework retains the same lineage and survives reload without admitting unrelated work', async t => { + const state = seed() + state.tasks.old.status = 'result-ready' + const f = await fixture(t, state), first = await f.start() + await first.rt.taskEndpoint('task-send-back', { id: 'old', note: 'Repair the original result' }, actor) + const child = Object.values(first.rt.state.tasks).find(row => row.reworkOf === 'old') + assert.ok(child) + assert.equal(child.projectId, 'p') + assert.equal(child.legacyProject.derivedFrom, 'old') + first.rt.projectTaskLifecycle.requireExecutable(child) + await first.close() + const second = await f.start() + second.rt.projectTaskLifecycle.requireExecutable(second.rt.state.tasks[child.id]) + assert.equal(second.rt.state.tasks[child.id].reworkOf, 'old') + assert.equal(second.rt.state.projectCompatibility.projects.p.count, 1) +}) + +test('canonical Tasks cannot inject old card leases, Handoffs or plugin routing', async t => { + const f = await fixture(t), { rt } = await f.start() + await rt.projectEndpoint('project-bind', { kind: 'bot', id: 'a', projectId: 'p' }, actor) + const task = await rt.taskEndpoint('task-create', { botId: 'a', projectId: 'p', title: 'Change the code', criteria: 'Regression passes', impact: 'Fix Project behavior' }, actor) + const row = rt.state.tasks[task.id], before = structuredClone(row) + for (const payload of [{ handoff: { from: 'Chief', to: 'Dev' } }, { lease: { holder: 'someone', takenAt: 1, expiresAt: 99999 } }, { evidence: [{ check: 'invented PASS' }] }]) { + await assert.rejects(rt.taskEndpoint('task-update', { id: row.id, ...payload }, actor), /contracts/) + assert.deepEqual(row, before) + } + let routing = 0 + rt.onTaskWrite = () => { ++routing; return { ownerBotId: 'someone' } } + await rt.taskEndpoint('task-update', { id: row.id, note: 'Additional requirement detail' }, actor) + assert.equal(routing, 0) + assert.equal(row.ownerBotId, 'a') +}) diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index c8fc9656..2e4b7994 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -1378,6 +1378,7 @@ test('new Project Tasks wait for Chief admission and cannot run or lower require admitTask(row, { id: 'admit-fixture', projectId: 'p1', taskId: row.id, executorRole: 'chief', runId: 'chief-fixture', initiatedBy: alice.memberId, reason: 'Goal, impact and acceptance checked' }) const started = await f.start('accepted') assert.equal(started.attempt.status, 'running') + await assert.rejects(f.rt.projectEndpoint('project-workflow', { projectId: 'p1', operation: 'assign', workflowId: started.attempt.workflowId, taskId: row.id }, bob), /task.run|controller/) await complete(f) await assert.rejects(f.rt.projectEndpoint('project-task-accept', { attemptId: f.attempt().id }, alice), /Chief/) }) diff --git a/patches/ds-bot/files/test/project-effects-runtime.test.js b/patches/ds-bot/files/test/project-effects-runtime.test.js index 661d72a6..41b7a8ef 100644 --- a/patches/ds-bot/files/test/project-effects-runtime.test.js +++ b/patches/ds-bot/files/test/project-effects-runtime.test.js @@ -34,7 +34,7 @@ test('real store rename acknowledges the same handoff/claim before any external assert.equal(f.executed(), 1) assert.equal(f.dispatched(), 1) const disk = JSON.parse(readFileSync(join(f.home, 'state.json'), 'utf8')) - assert.equal(Object.values(disk.projectEffects)[0].status, 'done') + assert.equal(Object.values(disk.projectEffects).find(row => row.taskId === 'task' && row.payload.handoffId === disk.tasks.task.collaboration.currentHandoffId).status, 'done') assert.equal(disk.tasks.task.collaboration.claim.workerId, 'controlled-worker') const result = await f.rt.projectEndpoint('project-development', { version: 'project-development/v1', kind: 'query', name: 'effects', projectId: 'p', payload: { taskId: 'task' } }, { memberId: 'owner-1' }) assert.equal(result.result.effects[0].committed, true) @@ -43,6 +43,7 @@ test('real store rename acknowledges the same handoff/claim before any external test('failed real handoff commit rolls back its offer and cannot be rescued by a timer', async t => { const f = await fixture(t), save = f.rt.save + const before = Object.keys(f.rt.state.projectEffects).sort() f.rt.save = async () => { throw Error('Injected write failure') } await assert.rejects(f.rt.projectPools.admit({ id: 'attempt', taskId: 'task' }, { memberId: 'owner-1' }), /write failure/) f.rt.save = save @@ -50,7 +51,7 @@ test('failed real handoff commit rolls back its offer and cannot be rescued by a assert.equal(f.executed(), 0) assert.equal(f.dispatched(), 0) assert.equal(f.rt.state.tasks.task.collaboration.history.length, 0) - assert.equal(Object.keys(f.rt.state.projectEffects).length, 0) + assert.deepEqual(Object.keys(f.rt.state.projectEffects).sort(), before) }) test('v1 Task input commits the inbox and notify intent before returning and creates no Task or worker', async t => { diff --git a/patches/ds-bot/files/test/workbench-personal.test.js b/patches/ds-bot/files/test/workbench-personal.test.js index 6d74e8fe..04296cc0 100644 --- a/patches/ds-bot/files/test/workbench-personal.test.js +++ b/patches/ds-bot/files/test/workbench-personal.test.js @@ -193,3 +193,22 @@ test('native file registration prevalidates the complete batch and preserves nat assert.equal(content[1].attachment.name, 'spec.txt') assert.deepEqual(saved, [{ name: 'spec.txt', data: 'SGk=' }]) }) + +test('Project-associated original groups retain their history ACL and require current Project access', () => { + const actor = { memberId: 'alice' }, state = { mainBotIds: ['chief'], bots: { chief: { id: 'chief', ownerId: 'alice' } }, rooms: { + own: { id: 'own', members: ['chief'], projectId: 'p', participants: [{ id: 'alice', kind: 'human', displayName: 'Alice' }] }, + other: { id: 'other', members: ['chief'], projectId: 'p' }, personal: { id: 'personal', members: ['chief'] } } } + let member = true + const rt = { state, botOwnedBy: (who, id) => state.bots[id]?.ownerId === who.memberId, roomMemberOf: (who, id) => who.memberId === 'alice' && id !== 'other', roomOwnedBy: () => false, + projectCan: () => member, botOf: id => state.bots[id], roomOf: id => state.rooms[id] } + assert.deepEqual(personalRoster(rt, actor).rooms.map(row => row.id), ['own', 'personal']) + assert.equal(requirePersonalSession(rt, actor, 'own'), 'own') + assert.equal(personalRoster(rt, actor).rooms[0].projectId, 'p') + assert.throws(() => requirePersonalSession(rt, { memberId: 'project-only' }, 'own'), /personal conversation/) + state.bots.chief.projectId = 'p' + assert.equal(requirePersonalSession(rt, actor, 'own'), 'own', 'original Project-bound group Bots retain the native group entry') + delete state.bots.chief.projectId + member = false + assert.throws(() => requirePersonalSession(rt, actor, 'own'), /personal conversation/) + assert.equal(requirePersonalSession(rt, actor, 'personal'), 'personal') +}) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index c057aa42..51643287 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -198,7 +198,7 @@ export function apply(ctx, config = {}) { offs.push(fb.tasks.onProjectProvider(({ hasTask } = {}) => service.projectTasks({ hasTask }))) } offs.push(fb.tasks.onWrite(task => { - if (fb.projects?.shared?.() && task?.projectId) return null + if (task?.projectTask || fb.projects?.shared?.() && task?.projectId) return null const result = service.ingest(task ?? {}) if (result?.matched === true && result.already !== true && result.decision?.kind === 'bot') { return { ownerBotId: result.decision.id }