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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.

### Changed

- **@overeng/notion-md**: Close two OTEL tracing gaps on the `edit` push path so its latency is diagnosable from a trace. The several back-to-back `gateway.pullPage` round-trips a single edit/push performs (init → status → observe → preflight → settle) previously emitted byte-identical `notion-md.gateway.pull-page` spans (same name, same `span.label`, same attrs), so the redundant status↔observe duplicate (#788) was visible-but-indistinguishable from the legitimate preflight/settle pulls. Added a `notion_md.pull.purpose` discriminator attribute (optional `purpose?` on the gateway `pullPage` arg — non-`edit` callers default to `other`, no churn) folded into the span label (e.g. `status·a1b2c3d4`). Also wrapped the interactive editor wait in a `notion-md.editor.session` span, so the previously-dark editor-exit→push boundary is a separable sibling leaf under `notion-md.edit` (editor-wait vs push-time). Instrumentation only — additive spans/attributes, zero result/error/exit-code change; a capture regression test pins the five distinct purposes + the editor-session span.
- **@overeng/notion-md**: Compacted the VRS decision log by removing six superseded `.decisions/` records and redirecting every citation to the surviving decision. The reconciler/placeholder design — `0005` inline id-carrying placeholders, `0010` visible-placeholder deletion, `0011` block-level reconciliation, `0014` reconciliation-as-universal-push-engine, `0015` renderer-symmetric Markdown↔block converter — is wholly superseded by **decision 0016** (refuse lossy pages), and the `0013` stateless in-buffer schema fingerprint by **decision 0017** (ephemeral file-engine session, drift detected from the engine's base snapshot). The six records were deleted rather than tombstoned (their rationale lives in `experiments.md`), and all references — the `schema-snapshot.ts` doc-comment, the `01-editor`/`04-fidelity`/`06-data-source` spec/requirements, `experiments.md`, `README.md`, and the surviving decision records `0003`/`0008`/`0009`/`0016`/`0017`/`0019` — were redirected to the superseding decision (as a live link where one is cited, or as historical prose naming the superseded concept). `0016`/`0017` were tightened to state the supersession once, and the duplicated "Refined by 0017" blockquotes on `0003`/`0008`/`0009` trimmed to one line. No decision IDs were renumbered; the six deleted IDs simply disappear (decision log is now 13 records: `0001`–`0004`, `0006`–`0009`, `0012`, `0016`–`0019`). Zero links resolve to a deleted record. Docs-only — no library surface change.

- **@overeng/notion-md / @overeng/notion-effect-client**: Consolidated the body/Markdown pipeline onto one canonical body form applied at BOTH Notion wire boundaries (decision 0019). The block-tree renderer (`treeToMarkdown`) joined every sibling block — including consecutive list items — with `\n\n`, so a tight Notion list pulled as a _loose_ CommonMark list (and a stray indented blank line appeared inside nested lists), while push canonicalized through remark: a two-oracle pull-loose / push-tight divergence that the whitespace-insensitive `semanticEquivalent` gate masked rather than caught. Fix: (1) `canonicalizeBlockMarkdown` now forces lists tight (`spread = false`) and folds line-ending normalization into its input; (2) **pull** is routed through it so pull and push agree by construction — the body a surface reads (`cat`/`edit`/file sync), the body hashed/compared, and the body pushed are the same canonical bytes; (3) `canonicalizeBlockMarkdown` + its remark/unified/unist deps **moved down into `@overeng/notion-effect-client`** beside `treeToMarkdown`/`media-url.ts`, and `observeFromSnapshots` canonicalizes the rendered body once at the source so the evidence fingerprint, the fidelity classifier, pull, hash, and push all see the same bytes (`semanticEquivalent` stays in notion-md as sync policy); (4) the dead client-side `markdownToBlocks` converter (`parseInlineMarkdown`/`parseMarkdownTable`/`parseTableRow`/`isTableSeparator`, plus its `mod.ts` re-exports) was deleted — under refuse-lossy/one-engine push goes through Notion's server-side parse, so it was on no path (no version bump; sole consumer); (5) the divergent duplicate `stripChildAnchors` (sync.ts filter-only vs tree.ts filter+normalize+collapse) deduped onto one definition; (6) the dead `renderedMarkdown ?? endpoint` pull fallback removed (rendered Markdown is total on the pull path; a missing value is now a defect, closing the latent "headings run together" symptom-2 trap). The renderer deliberately stays parseable-not-canonical (its `\n\n` joins must NOT be made block-type-aware). Behavior: an already-synced list-bearing page re-canonicalizes loose → tight once on the next pull (benign, base-hash restated); the known Case B residual (paragraph-after-list, #756) is unchanged. `demo/showcase.nmd` re-baselined to the canonical shape.
Expand Down
26 changes: 26 additions & 0 deletions packages/@overeng/notion-md/src/cli-output/edit/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type TuiApp, createTuiApp } from '@overeng/tui-react'

import { EditAction, EditState, editExitCode, editReducer, initialEditState } from './schema.ts'

let cached: TuiApp<EditState, EditAction> | undefined

/**
* TUI app definition for the `edit` command output.
*
* Built lazily (and memoized) rather than at module top level: an eager
* `createTuiApp` is a module-load side-effect that crashes the umbrella `notion`
* binary under Bun's concurrent command-tree import (#787, oven-sh/bun#30634).
* Mirrors notion-cli's `getDiffApp()`; drop the laziness once the Bun fix lands.
*
* `initial` carries an empty page placeholder — the handler dispatches stage
* transitions and the terminal `SetResult`/`SetError` over the real page; the
* page label itself comes through the view's `context` header.
*/
export const getEditApp = (): TuiApp<EditState, EditAction> =>
(cached ??= createTuiApp({
stateSchema: EditState,
actionSchema: EditAction,
initial: initialEditState('') as EditState,
reducer: editReducer,
exitCode: editExitCode,
}))
238 changes: 238 additions & 0 deletions packages/@overeng/notion-md/src/cli-output/edit/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* State/action/reducer for the `edit` command's output (the `/sk-cli-design`
* `app/schema/view` triple, mirroring notion-cli's DiffOutput).
*
* The engine emits staged progress through the `ProgressReporter` seam; the
* `progress-bridge` Layer turns each transition into an {@link EditAction}, and
* this reducer folds them onto a visible {@link EditState} the {@link view}
* renders through the active OutputMode. The terminal tag (`Success` /
* `Conflict` / `Error`) is set from the engine's `EditResult` (`SetResult`) or a
* caught failure (`SetError`).
*
* @module
*/

import { Schema } from 'effect'

import { ProblemItem, TaskItemSchema } from '@overeng/tui-react'

/** Engine stage ids (kept in sync with `ProgressStageId` in `progress.ts`). */
const StageId = Schema.Literal('observe', 'write-body', 'write-title', 'settle')

/**
* `edit` UI state. `Running` accumulates the live stages + any warnings until a
* terminal `SetResult`/`SetError` arrives; the terminal tags carry the stages so
* the final render still shows the (now-settled) stage list.
*/
export const EditState = Schema.Union(
Schema.TaggedStruct('Running', {
page: Schema.String,
stages: Schema.Array(TaskItemSchema),
warnings: Schema.Array(ProblemItem),
}),
Schema.TaggedStruct('Success', {
page: Schema.String,
stages: Schema.Array(TaskItemSchema),
warnings: Schema.Array(ProblemItem),
/** `true` when the editor buffer was unchanged (a no-op push). */
noChange: Schema.Boolean,
titleWritten: Schema.optional(Schema.Boolean),
}),
Schema.TaggedStruct('Conflict', {
page: Schema.String,
stages: Schema.Array(TaskItemSchema),
warnings: Schema.Array(ProblemItem),
/** Durable `<page>.conflict.md` the engine relocated the rough draft to. */
conflictPath: Schema.optional(Schema.String),
}),
Schema.TaggedStruct('Error', {
page: Schema.String,
stages: Schema.Array(TaskItemSchema),
warnings: Schema.Array(ProblemItem),
message: Schema.String,
}),
)
export type EditState = typeof EditState.Type

/**
* Engine `EditResult` projected into the action schema (the CLI's
* `editEditorPage` return shape, JSON-encodable for the seam).
*/
const EditResultPayload = Schema.Struct({
outcome: Schema.Literal('pushed', 'noop', 'conflict'),
conflictPath: Schema.optional(Schema.String),
/** Surfaced only when known; drives the summary line's `+ title` hint. */
titleWritten: Schema.optional(Schema.Boolean),
})

/** Actions the `edit` program dispatches (stage transitions, notes, terminal). */
export const EditAction = Schema.Union(
Schema.TaggedStruct('StageActive', { id: StageId, label: Schema.String }),
Schema.TaggedStruct('StageSucceed', {
id: StageId,
label: Schema.String,
message: Schema.optional(Schema.String),
}),
Schema.TaggedStruct('StageSkip', { id: StageId, label: Schema.String }),
Schema.TaggedStruct('StageFail', { id: StageId, label: Schema.String }),
Schema.TaggedStruct('Note', { message: Schema.String }),
Schema.TaggedStruct('SetResult', { result: EditResultPayload }),
Schema.TaggedStruct('SetError', { message: Schema.String }),
)
export type EditAction = typeof EditAction.Type

/** Initial state for an `edit <page>` run (before the first stage transition). */
export const initialEditState = (page: string): EditState => ({
_tag: 'Running',
page,
stages: [],
warnings: [],
})

/**
* Map an engine progress note to a WARNING {@link ProblemItem} with an actionable
* `fix:`. The conflict note names the durable `<page>.conflict.md`; the
* auto-merge note just reports that the push absorbed an upstream change.
*/
const noteToProblem = (message: string): ProblemItem => {
const conflictMatch = message.match(/conflict draft to (\S+?)(?:;|$)/u)
if (conflictMatch !== null) {
const path = conflictMatch[1] ?? '<page>.conflict.md'
return {
severity: 'warning',
name: path,
status: 'conflict',
details: 'remote changed and overlaps your edit — nothing pushed',
fixes: [`open ${path} to resolve, then re-run \`notion-md edit\``],
}
}
return {
severity: 'warning',
name: 'auto-merge',
status: 'merged',
details: message,
fixes: ['cat the page to confirm the merged result before further edits'],
}
}

/**
* Upsert a stage by id into the `TaskItem[]` (stage transitions arrive
* `active → succeed/skip/fail`, so a later transition replaces the earlier one).
* Append when first seen to preserve the engine's emission order.
*/
const upsertStage = ({
stages,
next,
}: {
readonly stages: readonly StageItem[]
readonly next: StageItem
}): readonly StageItem[] => {
const index = stages.findIndex((stage) => stage.id === next.id)
if (index === -1) return [...stages, next]
return stages.map((stage, i) => (i === index ? next : stage))
}

/** One stage row (the `TaskItem` inferred type the kit's `StageList` consumes). */
type StageItem = typeof TaskItemSchema.Type

/** Fold an {@link EditAction} onto the current {@link EditState}. */
export const editReducer = ({
state,
action,
}: {
state: EditState
action: EditAction
}): EditState => {
switch (action._tag) {
case 'StageActive':
return {
...state,
stages: upsertStage({
stages: state.stages,
next: {
id: action.id,
label: action.label,
status: 'active',
},
}),
}
case 'StageSucceed':
return {
...state,
stages: upsertStage({
stages: state.stages,
next: {
id: action.id,
label: action.label,
status: 'success',
...(action.message === undefined ? {} : { message: action.message }),
},
}),
}
case 'StageSkip':
return {
...state,
stages: upsertStage({
stages: state.stages,
next: {
id: action.id,
label: action.label,
status: 'skipped',
},
}),
}
case 'StageFail':
return {
...state,
stages: upsertStage({
stages: state.stages,
next: {
id: action.id,
label: action.label,
status: 'error',
},
}),
}
case 'Note':
return { ...state, warnings: [...state.warnings, noteToProblem(action.message)] }
case 'SetResult': {
const base = { page: state.page, stages: state.stages, warnings: state.warnings } as const
if (action.result.outcome === 'conflict') {
return {
_tag: 'Conflict',
...base,
...(action.result.conflictPath === undefined
? {}
: { conflictPath: action.result.conflictPath }),
}
}
return {
_tag: 'Success',
...base,
noChange: action.result.outcome === 'noop',
...(action.result.titleWritten === undefined
? {}
: { titleWritten: action.result.titleWritten }),
}
}
case 'SetError':
return {
_tag: 'Error',
page: state.page,
stages: state.stages,
warnings: state.warnings,
message: action.message,
}
}
}

/**
* In-app exit-code view of the terminal state. This is the TUI app's notion of
* the result, NOT a second process-exit path: the real exit code is owned by the
* `runMain` teardown (`editorExitCode`, exit-codes.ts), which maps the engine's
* `Exit`. The engine catches the conflict and returns a *success* `EditResult`
* for `edit`, so a conflict process-exits 0 today; this mapper mirrors that
* (only the engine's actual `Error` failure → 1) to avoid diverging from / double
* mapping against the teardown.
*/
export const editExitCode = (state: EditState): number => (state._tag === 'Error' ? 1 : 0)
51 changes: 51 additions & 0 deletions packages/@overeng/notion-md/src/cli-output/edit/view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type Atom } from '@effect-atom/atom'
import React from 'react'

import { CommandOutput, useTuiAtomValue } from '@overeng/tui-react'

import type { EditState } from './schema.ts'

/** Props for {@link EditView}. */
export interface EditViewProps {
readonly stateAtom: Atom.Atom<EditState>
}

/** Context header line for the `edit` output (`notion-md edit · <page>`). */
const contextLine = (page: string): string =>
page.length === 0 ? 'notion-md edit' : `notion-md edit · ${page}`

/** Build the dimmed `·`-joined summary line for a terminal state. */
const summaryParts = (state: EditState): readonly string[] => {
switch (state._tag) {
case 'Running':
return ['editing…']
case 'Success':
return state.noChange === true
? ['no changes']
: [`pushed${state.titleWritten === true ? ' · title' : ''}`, state.page]
case 'Conflict':
return ['1 page', 'conflict draft written', '0 pushed']
case 'Error':
return ['failed', state.message]
}
}

/**
* Render the `edit` command output through the shared `/sk-cli-design` kit: the
* conflict / auto-merge WARNING comes through `problems` (with an actionable
* `fix:`), the staged write progress through `stages`, and the outcome through
* the dimmed summary line. All glyph/color/stream choices flow through the active
* OutputMode, so a pipe gets clean JSON/log and a TTY gets the live render.
*/
export const EditView = ({ stateAtom }: EditViewProps): React.ReactElement => {
const state = useTuiAtomValue(stateAtom)
return (
<CommandOutput
context={contextLine(state.page)}
problems={state.warnings}
sections={[]}
stages={state.stages}
summary={summaryParts(state)}
/>
)
}
26 changes: 26 additions & 0 deletions packages/@overeng/notion-md/src/cli-output/plan/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type TuiApp, createTuiApp } from '@overeng/tui-react'

import { PlanAction, PlanState, initialPlanState, planExitCode, planReducer } from './schema.ts'

let cached: TuiApp<PlanState, PlanAction> | undefined

/**
* TUI app definition for the read-only `plan` command output.
*
* Built lazily (and memoized) rather than at module top level: an eager
* `createTuiApp` is a module-load side-effect that crashes the umbrella `notion`
* binary under Bun's concurrent command-tree import (#787, oven-sh/bun#30634).
* Mirrors `getSyncApp()`; drop the laziness once the Bun fix lands.
*
* `initial` carries an empty target placeholder — the handler dispatches the real
* target (`SetTarget`) and the terminal result; the target label surfaces through
* the view's `context` header.
*/
export const getPlanApp = (): TuiApp<PlanState, PlanAction> =>
(cached ??= createTuiApp({
stateSchema: PlanState,
actionSchema: PlanAction,
initial: initialPlanState('') as PlanState,
reducer: planReducer,
exitCode: planExitCode,
}))
Loading