2026-06-02 23:21:26 -04:00
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" ;
import { z } from "zod" ;
import { db } from "../db.js" ;
import { eq } from "../drizzle.js" ;
import { agentRuns , auditLog , markdownBacklogItems } from "../schema.js" ;
feat(markdown-backlog): close the sync loop with DB → frontmatter export
Until now the markdown importer was one-way (plans/*.md → DB). Any
agent-driven status flip via claim_task / complete_task would be
clobbered on the next importer sweep. This change closes the loop: the
DB now projects status, priority, agent_prompt, and updated_at back
into the file's frontmatter, preserving body bytes, key order, and
every other frontmatter key.
New: packages/database/src/markdown-backlog/export.ts
- `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases
(round-trip identity, status flip, priority flip, agent_prompt
null/block-scalar/single-line variants, body preservation,
trailing-newline preservation, idempotent re-application).
- `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic
write (tmp + rename) and tenant fencing. Returns a structured
result so callers can surface what happened in their response.
Wired into:
- `claim_task` MCP tool — exports on the ready → in_progress flip.
- `complete_task` MCP tool — exports on any finalStatus transition.
- `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt
edits made through the app UI.
Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`,
plus a copy in `import-markdown-backlog.ts`): walk up from the source
file looking for `pnpm-workspace.yaml`, falling back to env var or cwd.
This fixes a class of bug where `pnpm --filter <pkg>` cd's into the
package directory and breaks naive cwd-based path resolution — the
importer was deleting all 44 rows during smoke testing before this fix
because it found zero files in `packages/database/plans/`.
`config/CursorSync.md`: documents the new two-way contract, the
DB-wins-on-allow-list conflict policy, and the
MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments
where `plans/` isn't checked out.
Smoke verified end-to-end against the homelab DB: claim flips file
status to in_progress, complete flips it back to ready, importer
round-trips with stable content_hash (true no-op), agent identity
preserved throughout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts" ;
import { resolveRepoRoot } from "../lib/repo-root.js" ;
2026-06-02 23:21:26 -04:00
import { toolCatch , toolErr , toolOk } from "./tool-result.js" ;
const outcomeSchema = z . enum ( [ "succeeded" , "failed" , "cancelled" , "stalled" ] ) ;
const finalStatusSchema = z . enum ( [
"done" ,
"blocked" ,
"ready" ,
"in_progress" ,
"cancelled" ,
] ) ;
const tokenCount = z . number ( ) . int ( ) . nonnegative ( ) ;
const completeTaskInputSchema = z . object ( {
runId : z.string ( ) . uuid ( ) ,
outcome : outcomeSchema ,
tokensInput : tokenCount.optional ( ) ,
tokensOutput : tokenCount.optional ( ) ,
tokensTotal : tokenCount.optional ( ) ,
notes : z.string ( ) . max ( 2000 ) . optional ( ) ,
error : z.string ( ) . max ( 2000 ) . optional ( ) ,
finalStatus : finalStatusSchema.optional ( ) ,
2026-06-03 11:11:35 -04:00
client : z
. string ( )
. min ( 1 )
. max ( 50 )
. optional ( )
. describe (
"Late-bound client identifier — only needed if you didn't supply one at claim time, or if it changed mid-session. Merged into existing run metadata; existing keys take precedence unless overridden." ,
) ,
model : z
. string ( )
. min ( 1 )
. max ( 100 )
. optional ( )
. describe (
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it." ,
) ,
2026-06-02 23:21:26 -04:00
} ) ;
type CompleteTaskInput = z . infer < typeof completeTaskInputSchema > ;
function resolveTokensTotal ( input : CompleteTaskInput ) : number | null {
if ( typeof input . tokensTotal === "number" ) return input . tokensTotal ;
if (
typeof input . tokensInput === "number" &&
typeof input . tokensOutput === "number"
) {
return input . tokensInput + input . tokensOutput ;
}
return null ;
}
function resolveFinalStatus (
input : CompleteTaskInput ,
) : z . infer < typeof finalStatusSchema > | null {
if ( input . finalStatus ) return input . finalStatus ;
if ( input . outcome === "succeeded" ) return "done" ;
if ( input . outcome === "failed" ) return "blocked" ;
return null ;
}
export function registerCompleteTaskTool ( mcp : McpServer ) : void {
mcp . registerTool (
"complete_task" ,
{
description :
"Close an agent_runs row at session end with an outcome and token totals. Optionally flips the backlog item to a terminal status. Errors if the run is already closed." ,
inputSchema : completeTaskInputSchema ,
} ,
async ( args ) = > {
try {
const input = completeTaskInputSchema . parse ( args ) ;
const [ run ] = await db
. select ( )
. from ( agentRuns )
. where ( eq ( agentRuns . id , input . runId ) )
. limit ( 1 ) ;
if ( ! run ) return toolErr ( "Run not found" ) ;
if ( run . finishedAt !== null ) {
return toolErr (
` Run already finished (outcome: ${ run . outcome ? ? "unknown" } ) ` ,
) ;
}
const [ backlogItem ] = await db
. select ( )
. from ( markdownBacklogItems )
. where ( eq ( markdownBacklogItems . id , run . backlogItemId ) )
. limit ( 1 ) ;
if ( ! backlogItem || backlogItem . workspaceId !== run . workspaceId ) {
return toolErr ( "Backlog item missing" ) ;
}
const tokensTotal = resolveTokensTotal ( input ) ;
const finalStatus = resolveFinalStatus ( input ) ;
const finishedAt = new Date ( ) ;
2026-06-03 11:11:35 -04:00
// Build the merged metadata object once, outside the txn, so we
// can also stamp the same shape into the audit log without
// re-reading the row. Late-bound `client`/`model` from this call
// override whatever claim_task wrote; that's the right policy
// because the latest caller is the one we can verify.
const incomingMeta : Record < string , unknown > = { } ;
if ( input . client ) incomingMeta . client = input . client ;
if ( input . model ) incomingMeta . model = input . model ;
const hasIncomingMeta = Object . keys ( incomingMeta ) . length > 0 ;
const mergedMeta = hasIncomingMeta
? { . . . ( run . metadata ? ? { } ) , . . . incomingMeta }
: run . metadata ;
2026-06-02 23:21:26 -04:00
await db . transaction ( async ( tx ) = > {
await tx
. update ( agentRuns )
. set ( {
finishedAt ,
outcome : input.outcome ,
tokensInput : input.tokensInput ? ? null ,
tokensOutput : input.tokensOutput ? ? null ,
tokensTotal ,
notes : input.notes ? ? null ,
error : input.error ? ? null ,
2026-06-03 11:11:35 -04:00
. . . ( hasIncomingMeta ? { metadata : mergedMeta } : { } ) ,
2026-06-02 23:21:26 -04:00
} )
. where ( eq ( agentRuns . id , input . runId ) ) ;
if ( finalStatus ) {
await tx
. update ( markdownBacklogItems )
. set ( { status : finalStatus , updatedAt : new Date ( ) } )
. where ( eq ( markdownBacklogItems . id , run . backlogItemId ) ) ;
}
await tx . insert ( auditLog ) . values ( {
workspaceId : run.workspaceId ,
actorUserId : run.actorUserId ? ? null ,
action : "task.completed" ,
targetType : "agent_run" ,
targetId : input.runId ,
metadata : {
outcome : input.outcome ,
finalStatus ,
backlogItemId : run.backlogItemId ,
tokensTotal ,
2026-06-03 11:11:35 -04:00
. . . ( mergedMeta ? ? { } ) ,
2026-06-02 23:21:26 -04:00
} ,
} ) ;
} ) ;
feat(markdown-backlog): close the sync loop with DB → frontmatter export
Until now the markdown importer was one-way (plans/*.md → DB). Any
agent-driven status flip via claim_task / complete_task would be
clobbered on the next importer sweep. This change closes the loop: the
DB now projects status, priority, agent_prompt, and updated_at back
into the file's frontmatter, preserving body bytes, key order, and
every other frontmatter key.
New: packages/database/src/markdown-backlog/export.ts
- `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases
(round-trip identity, status flip, priority flip, agent_prompt
null/block-scalar/single-line variants, body preservation,
trailing-newline preservation, idempotent re-application).
- `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic
write (tmp + rename) and tenant fencing. Returns a structured
result so callers can surface what happened in their response.
Wired into:
- `claim_task` MCP tool — exports on the ready → in_progress flip.
- `complete_task` MCP tool — exports on any finalStatus transition.
- `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt
edits made through the app UI.
Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`,
plus a copy in `import-markdown-backlog.ts`): walk up from the source
file looking for `pnpm-workspace.yaml`, falling back to env var or cwd.
This fixes a class of bug where `pnpm --filter <pkg>` cd's into the
package directory and breaks naive cwd-based path resolution — the
importer was deleting all 44 rows during smoke testing before this fix
because it found zero files in `packages/database/plans/`.
`config/CursorSync.md`: documents the new two-way contract, the
DB-wins-on-allow-list conflict policy, and the
MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments
where `plans/` isn't checked out.
Smoke verified end-to-end against the homelab DB: claim flips file
status to in_progress, complete flips it back to ready, importer
round-trips with stable content_hash (true no-op), agent identity
preserved throughout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
// Project the new state back to the markdown file. This closes
// the sync loop so a subsequent importer pass is a no-op. We do
// this OUTSIDE the txn because writing to disk inside a DB
// transaction would hold locks across an I/O call; the worst
// case here is "DB updated but file write failed," which the
// operator can reconcile by re-running the importer in either
// direction.
let exportSummary :
| { changed : boolean ; repoPath : string }
| { error : string }
| null = null ;
const repoRoot = resolveRepoRoot ( ) ;
if ( repoRoot && finalStatus !== null ) {
try {
const result = await exportBacklogItemToMarkdown ( db , {
workspaceId : run.workspaceId ,
backlogItemId : run.backlogItemId ,
repoRootAbs : repoRoot ,
} ) ;
if ( result . ok ) {
exportSummary = {
changed : result.changed ,
repoPath : result.repoPath ,
} ;
} else {
exportSummary = { error : ` ${ result . reason } : ${ result . detail } ` } ;
console . error (
` [complete_task] markdown export failed ( ${ result . reason } ): ${ result . detail } ` ,
) ;
}
} catch ( e ) {
const msg = e instanceof Error ? e.message : String ( e ) ;
exportSummary = { error : msg } ;
console . error ( "[complete_task] markdown export threw:" , e ) ;
}
}
2026-06-02 23:21:26 -04:00
return toolOk ( {
runId : input.runId ,
finishedAt : finishedAt.toISOString ( ) ,
finalStatus : finalStatus ? ? null ,
tokensTotal ,
outcome : input.outcome ,
feat(markdown-backlog): close the sync loop with DB → frontmatter export
Until now the markdown importer was one-way (plans/*.md → DB). Any
agent-driven status flip via claim_task / complete_task would be
clobbered on the next importer sweep. This change closes the loop: the
DB now projects status, priority, agent_prompt, and updated_at back
into the file's frontmatter, preserving body bytes, key order, and
every other frontmatter key.
New: packages/database/src/markdown-backlog/export.ts
- `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases
(round-trip identity, status flip, priority flip, agent_prompt
null/block-scalar/single-line variants, body preservation,
trailing-newline preservation, idempotent re-application).
- `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic
write (tmp + rename) and tenant fencing. Returns a structured
result so callers can surface what happened in their response.
Wired into:
- `claim_task` MCP tool — exports on the ready → in_progress flip.
- `complete_task` MCP tool — exports on any finalStatus transition.
- `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt
edits made through the app UI.
Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`,
plus a copy in `import-markdown-backlog.ts`): walk up from the source
file looking for `pnpm-workspace.yaml`, falling back to env var or cwd.
This fixes a class of bug where `pnpm --filter <pkg>` cd's into the
package directory and breaks naive cwd-based path resolution — the
importer was deleting all 44 rows during smoke testing before this fix
because it found zero files in `packages/database/plans/`.
`config/CursorSync.md`: documents the new two-way contract, the
DB-wins-on-allow-list conflict policy, and the
MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments
where `plans/` isn't checked out.
Smoke verified end-to-end against the homelab DB: claim flips file
status to in_progress, complete flips it back to ready, importer
round-trips with stable content_hash (true no-op), agent identity
preserved throughout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
export : exportSummary ,
2026-06-02 23:21:26 -04:00
} ) ;
} catch ( e ) {
return toolCatch ( e ) ;
}
} ,
) ;
}