P2: deterministic scheduler → real Now/Next/Later #37
@@ -7,23 +7,26 @@ const here = dirname(fileURLToPath(import.meta.url))
|
|||||||
const MAIN = join(here, '..', 'out', 'main', 'index.js')
|
const MAIN = join(here, '..', 'out', 'main', 'index.js')
|
||||||
|
|
||||||
// Opt-in only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process
|
// Opt-in only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process
|
||||||
// reconciles the real repo via .env.local, and asserts real issues render.
|
// reconciles the real repo via .env.local, and asserts real data renders.
|
||||||
test.describe('live backlog', () => {
|
test.describe('live backlog', () => {
|
||||||
test('The pot shows real gitea issues', async () => {
|
test('The pot + Focus render real gitea data', async () => {
|
||||||
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
|
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
|
||||||
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
|
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
|
||||||
const win = await app.firstWindow()
|
const win = await app.firstWindow()
|
||||||
await win.waitForLoadState('domcontentloaded')
|
await win.waitForLoadState('domcontentloaded')
|
||||||
|
const rail = win.getByRole('navigation', { name: 'Primary' })
|
||||||
|
|
||||||
await win.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: 'The pot' }).click()
|
// The pot — waiting here also lets the reconcile (issues + deps) complete
|
||||||
// our filed dogfood issue #1 (now closed → Done column) — real gitea data
|
await rail.getByRole('button', { name: 'The pot' }).click()
|
||||||
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 15000 })
|
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
|
||||||
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
|
||||||
|
|
||||||
|
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These
|
||||||
|
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
|
||||||
|
await rail.getByRole('button', { name: 'Morning service' }).click()
|
||||||
|
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
|
||||||
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
|
||||||
|
|
||||||
await win.screenshot({
|
|
||||||
path: join(here, '.artifacts', 'screens', 'live-board.png'),
|
|
||||||
fullPage: true,
|
|
||||||
animations: 'disabled',
|
|
||||||
})
|
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,9 +51,15 @@ export function registerGiteaIpc(): void {
|
|||||||
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
|
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
|
||||||
|
|
||||||
ipcMain.handle('gitea:reconcile', async () => {
|
ipcMain.handle('gitea:reconcile', async () => {
|
||||||
if (!client) return { configured: false, issues: [], milestones: [] }
|
if (!client) return { configured: false, issues: [], milestones: [], deps: [] }
|
||||||
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||||
return { configured: true, issues, milestones }
|
// dependency edges among the open scope (the scheduler only plans what's left)
|
||||||
|
const open = issues.filter((i) => i.state === 'open')
|
||||||
|
const perIssue = await Promise.all(
|
||||||
|
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
|
||||||
|
)
|
||||||
|
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
|
||||||
|
return { configured: true, issues, milestones, deps }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
||||||
|
import { type FocusView } from '../../lib/backlog.js'
|
||||||
import { BurnUpCone } from '../charts/chart.js'
|
import { BurnUpCone } from '../charts/chart.js'
|
||||||
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Morning service — the Now/Next/Later focus cards + the milestone burn-up cone.
|
* Morning service — the Now/Next/Later focus cards + the milestone burn-up cone.
|
||||||
* Data is fixture (data.js) until P2's scheduler + Monte Carlo feed it.
|
* `focus` (real scheduler output) overrides the demo fixture when gitea is
|
||||||
|
* configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice).
|
||||||
*/
|
*/
|
||||||
export function FocusScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
|
export function FocusScreen({
|
||||||
|
onOpenIssue,
|
||||||
|
focus,
|
||||||
|
}: {
|
||||||
|
onOpenIssue: (issue: IssueRef) => void
|
||||||
|
focus?: FocusView
|
||||||
|
}) {
|
||||||
|
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
||||||
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
||||||
<Card
|
<Card
|
||||||
overline={slot}
|
overline={slot}
|
||||||
@@ -79,9 +88,9 @@ export function FocusScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||||
<FocusRow slot="Now" issue={FOCUS.now} jade />
|
{view.now ? <FocusRow slot="Now" issue={view.now} jade /> : null}
|
||||||
<FocusRow slot="Next" issue={FOCUS.next} />
|
{view.next ? <FocusRow slot="Next" issue={view.next} /> : null}
|
||||||
<FocusRow slot="Later" issue={FOCUS.later} />
|
{view.later ? <FocusRow slot="Later" issue={view.later} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import logoIcon from '../../design/assets/logo-icon.png'
|
import logoIcon from '../../design/assets/logo-icon.png'
|
||||||
import type { IssueRef } from '../../data/fixtures.js'
|
import type { IssueRef } from '../../data/fixtures.js'
|
||||||
import { issuesToBoardColumns } from '../../lib/backlog.js'
|
import { issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
|
||||||
import { useBacklog } from '../../lib/use-backlog.js'
|
import { useBacklog } from '../../lib/use-backlog.js'
|
||||||
import { PrimitivesGallery } from '../gallery.js'
|
import { PrimitivesGallery } from '../gallery.js'
|
||||||
import { BoardScreen } from '../screens/board-screen.js'
|
import { BoardScreen } from '../screens/board-screen.js'
|
||||||
@@ -87,6 +87,7 @@ export function AppShell() {
|
|||||||
const [readIds, setReadIds] = useState<number[]>([])
|
const [readIds, setReadIds] = useState<number[]>([])
|
||||||
const backlog = useBacklog()
|
const backlog = useBacklog()
|
||||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
||||||
|
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||||
@@ -157,7 +158,7 @@ export function AppShell() {
|
|||||||
const renderScreen = () => {
|
const renderScreen = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'focus':
|
case 'focus':
|
||||||
return <FocusScreen onOpenIssue={openIssue} />
|
return <FocusScreen onOpenIssue={openIssue} focus={focus} />
|
||||||
case 'standup':
|
case 'standup':
|
||||||
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
||||||
case 'board':
|
case 'board':
|
||||||
|
|||||||
9
apps/desktop/src/renderer/src/global.d.ts
vendored
9
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -1,9 +1,14 @@
|
|||||||
import type { GiteaIssue, GiteaMilestone } from '@commitea/core'
|
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||||
|
|
||||||
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
|
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
|
||||||
export interface GiteaBridge {
|
export interface GiteaBridge {
|
||||||
status(): Promise<{ configured: boolean; repo: string | null }>
|
status(): Promise<{ configured: boolean; repo: string | null }>
|
||||||
reconcile(): Promise<{ configured: boolean; issues: GiteaIssue[]; milestones: GiteaMilestone[] }>
|
reconcile(): Promise<{
|
||||||
|
configured: boolean
|
||||||
|
issues: GiteaIssue[]
|
||||||
|
milestones: GiteaMilestone[]
|
||||||
|
deps: DependencyEdge[]
|
||||||
|
}>
|
||||||
getIssue(index: number): Promise<GiteaIssue | null>
|
getIssue(index: number): Promise<GiteaIssue | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { type GiteaIssue, inferColumnV0, type LifecycleColumn } from '@commitea/core'
|
import {
|
||||||
|
type DependencyEdge,
|
||||||
|
type GiteaIssue,
|
||||||
|
inferColumnV0,
|
||||||
|
type LifecycleColumn,
|
||||||
|
schedule,
|
||||||
|
type ScheduledItem,
|
||||||
|
selectFocus,
|
||||||
|
} from '@commitea/core'
|
||||||
|
|
||||||
import { type BoardColumn, type BoardIssue } from '../data/fixtures.js'
|
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||||
diagnosis: 'Diagnosis',
|
diagnosis: 'Diagnosis',
|
||||||
@@ -37,3 +45,35 @@ export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] {
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FocusView {
|
||||||
|
now: FocusIssue | null
|
||||||
|
next: FocusIssue | null
|
||||||
|
later: FocusIssue | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFocusIssue(item: ScheduledItem | null): FocusIssue | null {
|
||||||
|
if (!item) return null
|
||||||
|
return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the deterministic scheduler over the open backlog and take the top three
|
||||||
|
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
||||||
|
* edges come from gitea's native issue dependencies.
|
||||||
|
*/
|
||||||
|
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView {
|
||||||
|
const open = issues.filter((i) => i.state === 'open')
|
||||||
|
const plan = schedule(
|
||||||
|
open.map((i) => ({
|
||||||
|
number: i.number,
|
||||||
|
title: i.title,
|
||||||
|
labels: i.labels,
|
||||||
|
estimateDays: i.facts.estimateDays,
|
||||||
|
priority: i.facts.priority,
|
||||||
|
})),
|
||||||
|
deps,
|
||||||
|
)
|
||||||
|
const f = selectFocus(plan)
|
||||||
|
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import type { GiteaIssue, GiteaMilestone } from '@commitea/core'
|
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||||
|
|
||||||
export type BacklogState =
|
export type BacklogState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'unconfigured' }
|
| { status: 'unconfigured' }
|
||||||
| { status: 'error'; message: string }
|
| { status: 'error'; message: string }
|
||||||
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[] }
|
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile the managed repo once on mount, through the main-process bridge.
|
* Reconcile the managed repo once on mount, through the main-process bridge.
|
||||||
@@ -24,7 +24,7 @@ export function useBacklog(): BacklogState {
|
|||||||
if (!alive) return
|
if (!alive) return
|
||||||
setState(
|
setState(
|
||||||
r.configured
|
r.configured
|
||||||
? { status: 'ready', issues: r.issues, milestones: r.milestones }
|
? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps }
|
||||||
: { status: 'unconfigured' },
|
: { status: 'unconfigured' },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface GiteaClient {
|
|||||||
listIssues(opts?: ListIssuesOptions): Promise<GiteaIssue[]>
|
listIssues(opts?: ListIssuesOptions): Promise<GiteaIssue[]>
|
||||||
/** Fetch every milestone (all pages). */
|
/** Fetch every milestone (all pages). */
|
||||||
listMilestones(): Promise<GiteaMilestone[]>
|
listMilestones(): Promise<GiteaMilestone[]>
|
||||||
|
/** The issue indices this issue depends on (its blockers). */
|
||||||
|
getIssueDependencies(index: number): Promise<number[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
|
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
|
||||||
@@ -156,5 +158,10 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
|
|||||||
)
|
)
|
||||||
return raw.map(normalizeMilestone)
|
return raw.map(normalizeMilestone)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getIssueDependencies(index) {
|
||||||
|
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
|
||||||
|
return raw.map((d) => d.number)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,12 @@ export type {
|
|||||||
|
|
||||||
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
|
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
|
||||||
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'
|
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'
|
||||||
|
|
||||||
|
export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
|
||||||
|
export type {
|
||||||
|
DependencyEdge,
|
||||||
|
Focus,
|
||||||
|
SchedulableIssue,
|
||||||
|
ScheduledItem,
|
||||||
|
SchedulePlan,
|
||||||
|
} from './scheduler/scheduler-v0.js'
|
||||||
|
|||||||
99
packages/core/src/scheduler/scheduler-v0.test.ts
Normal file
99
packages/core/src/scheduler/scheduler-v0.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { type DependencyEdge, schedule, type SchedulableIssue, selectFocus } from './scheduler-v0.js'
|
||||||
|
|
||||||
|
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
|
||||||
|
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('schedule', () => {
|
||||||
|
it('lays open issues out serially with cumulative start/finish', () => {
|
||||||
|
const plan = schedule([issue(1, { estimateDays: 2 }), issue(2, { estimateDays: 3 })], [])
|
||||||
|
expect(plan.cycle).toBeNull()
|
||||||
|
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
||||||
|
// total span = sum of durations, no overlap (single serial worker)
|
||||||
|
const spans = plan.items.map((i) => [i.startDay, i.endDay])
|
||||||
|
expect(spans).toContainEqual([0, expect.any(Number)])
|
||||||
|
expect(byN[1].durationDays + byN[2].durationDays).toBe(5)
|
||||||
|
expect(Math.max(...plan.items.map((i) => i.endDay))).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects dependencies: a dependency is scheduled before its dependent', () => {
|
||||||
|
// #2 depends on #1
|
||||||
|
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
|
||||||
|
const plan = schedule([issue(2, { priority: 1 }), issue(1, { priority: 4 })], edges)
|
||||||
|
const order = plan.items.map((i) => i.number)
|
||||||
|
expect(order.indexOf(1)).toBeLessThan(order.indexOf(2))
|
||||||
|
// #2 records #1 as a blocker; #1 records #2 as blocked
|
||||||
|
const two = plan.items.find((i) => i.number === 2)!
|
||||||
|
expect(two.blockedBy).toEqual([1])
|
||||||
|
const one = plan.items.find((i) => i.number === 1)!
|
||||||
|
expect(one.blocks).toEqual([2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('breaks ties among ready roots by priority, then estimate', () => {
|
||||||
|
const plan = schedule(
|
||||||
|
[issue(1, { priority: 3 }), issue(2, { priority: 1 }), issue(3, { priority: 2 })],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
expect(plan.items.map((i) => i.number)).toEqual([2, 3, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects a dependency cycle instead of looping', () => {
|
||||||
|
const edges: DependencyEdge[] = [
|
||||||
|
{ issue: 1, dependsOn: 2 },
|
||||||
|
{ issue: 2, dependsOn: 1 },
|
||||||
|
]
|
||||||
|
const plan = schedule([issue(1), issue(2)], edges)
|
||||||
|
expect(plan.items).toHaveLength(0)
|
||||||
|
expect(plan.cycle).not.toBeNull()
|
||||||
|
expect(plan.cycle!.sort()).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops dependencies on out-of-scope (closed) issues', () => {
|
||||||
|
// #2 depends on #9 which isn't in scope → treated as satisfied
|
||||||
|
const plan = schedule([issue(2)], [{ issue: 2, dependsOn: 9 }])
|
||||||
|
expect(plan.cycle).toBeNull()
|
||||||
|
expect(plan.items.find((i) => i.number === 2)!.blockedBy).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the longest dependency chain critical', () => {
|
||||||
|
// chain 1→2→3 (each 3d = 9d) vs standalone 4 (2d): 1,2,3 critical, 4 not
|
||||||
|
const edges: DependencyEdge[] = [
|
||||||
|
{ issue: 2, dependsOn: 1 },
|
||||||
|
{ issue: 3, dependsOn: 2 },
|
||||||
|
]
|
||||||
|
const issues = [
|
||||||
|
issue(1, { estimateDays: 3 }),
|
||||||
|
issue(2, { estimateDays: 3 }),
|
||||||
|
issue(3, { estimateDays: 3 }),
|
||||||
|
issue(4, { estimateDays: 2 }),
|
||||||
|
]
|
||||||
|
const plan = schedule(issues, edges)
|
||||||
|
const crit = plan.items.filter((i) => i.critical).map((i) => i.number).sort()
|
||||||
|
expect(crit).toEqual([1, 2, 3])
|
||||||
|
expect(plan.items.find((i) => i.number === 4)!.critical).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults a null estimate to DEFAULT_ESTIMATE_DAYS', () => {
|
||||||
|
const plan = schedule([issue(1, { estimateDays: null })], [])
|
||||||
|
expect(plan.items[0].durationDays).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectFocus', () => {
|
||||||
|
it('picks the first three of the ordered plan as now/next/later', () => {
|
||||||
|
const plan = schedule([issue(1, { priority: 1 }), issue(2, { priority: 2 }), issue(3, { priority: 3 })], [])
|
||||||
|
const focus = selectFocus(plan)
|
||||||
|
expect(focus.now?.number).toBe(1)
|
||||||
|
expect(focus.next?.number).toBe(2)
|
||||||
|
expect(focus.later?.number).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls past the end of a short plan', () => {
|
||||||
|
const focus = selectFocus(schedule([issue(1)], []))
|
||||||
|
expect(focus.now?.number).toBe(1)
|
||||||
|
expect(focus.next).toBeNull()
|
||||||
|
expect(focus.later).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
182
packages/core/src/scheduler/scheduler-v0.ts
Normal file
182
packages/core/src/scheduler/scheduler-v0.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic scheduler, v0. Orders the open backlog by dependency topology,
|
||||||
|
* then priority, then estimate; lays it out on a single serial capacity and
|
||||||
|
* marks the critical path. The LLM never does this — it's plain code.
|
||||||
|
*
|
||||||
|
* v0 simplifications (each is a later slice, not a hack):
|
||||||
|
* - single serial worker; per-person capacity is #8.
|
||||||
|
* - point durations from estimate labels; Monte Carlo cones are #10.
|
||||||
|
* - a null estimate defaults to DEFAULT_ESTIMATE_DAYS.
|
||||||
|
* Dependencies on out-of-scope issues (closed/done, or not passed in) are
|
||||||
|
* treated as satisfied and dropped — you schedule what's left to do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_ESTIMATE_DAYS = 2
|
||||||
|
|
||||||
|
export interface SchedulableIssue {
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
labels: string[]
|
||||||
|
/** From label facts; null when unestimated. */
|
||||||
|
estimateDays: number | null
|
||||||
|
/** 1 (most urgent) … 4; null when unset. */
|
||||||
|
priority: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DependencyEdge {
|
||||||
|
issue: number
|
||||||
|
dependsOn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledItem {
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
labels: string[]
|
||||||
|
/** 0-based position in the plan. */
|
||||||
|
order: number
|
||||||
|
/** Working-day offsets from now. */
|
||||||
|
startDay: number
|
||||||
|
endDay: number
|
||||||
|
durationDays: number
|
||||||
|
/** In-scope dependencies (all still open, so all still blocking). */
|
||||||
|
blockedBy: number[]
|
||||||
|
/** Issues that depend on this one. */
|
||||||
|
blocks: number[]
|
||||||
|
/** On a longest-duration dependency chain. */
|
||||||
|
critical: boolean
|
||||||
|
rationale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulePlan {
|
||||||
|
items: ScheduledItem[]
|
||||||
|
/** A dependency cycle (issue numbers) if one was found — the plan is then empty. */
|
||||||
|
cycle: number[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationOf(issue: SchedulableIssue): number {
|
||||||
|
return issue.estimateDays ?? DEFAULT_ESTIMATE_DAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-first comparison among ready nodes: priority (1 first), then larger estimate, then number. */
|
||||||
|
function readyRank(a: SchedulableIssue, b: SchedulableIssue): number {
|
||||||
|
const pa = a.priority ?? 99
|
||||||
|
const pb = b.priority ?? 99
|
||||||
|
if (pa !== pb) return pa - pb
|
||||||
|
const da = durationOf(a)
|
||||||
|
const db = durationOf(b)
|
||||||
|
if (da !== db) return db - da
|
||||||
|
return a.number - b.number
|
||||||
|
}
|
||||||
|
|
||||||
|
function rationaleFor(item: {
|
||||||
|
critical: boolean
|
||||||
|
blockedBy: number[]
|
||||||
|
blocks: number[]
|
||||||
|
priority: number | null
|
||||||
|
}): string {
|
||||||
|
const blocksNote = item.blocks.length ? `unblocks ${item.blocks.map((n) => `#${n}`).join(', ')}` : ''
|
||||||
|
if (item.critical) return ['on the critical path', blocksNote].filter(Boolean).join(' · ')
|
||||||
|
if (item.blockedBy.length) return `waits on ${item.blockedBy.map((n) => `#${n}`).join(', ')}`
|
||||||
|
if (blocksNote) return blocksNote
|
||||||
|
if (item.priority != null) return `p/${item.priority} · ready`
|
||||||
|
return 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schedule(issues: SchedulableIssue[], edges: DependencyEdge[]): SchedulePlan {
|
||||||
|
const byNumber = new Map(issues.map((i) => [i.number, i]))
|
||||||
|
|
||||||
|
// keep only edges whose endpoints are both in scope
|
||||||
|
const scoped = edges.filter((e) => byNumber.has(e.issue) && byNumber.has(e.dependsOn))
|
||||||
|
const deps = new Map<number, number[]>() // issue → its dependencies
|
||||||
|
const dependents = new Map<number, number[]>() // issue → who depends on it
|
||||||
|
for (const i of issues) {
|
||||||
|
deps.set(i.number, [])
|
||||||
|
dependents.set(i.number, [])
|
||||||
|
}
|
||||||
|
for (const e of scoped) {
|
||||||
|
deps.get(e.issue)!.push(e.dependsOn)
|
||||||
|
dependents.get(e.dependsOn)!.push(e.issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn topo with priority tie-break
|
||||||
|
const indegree = new Map(issues.map((i) => [i.number, deps.get(i.number)!.length]))
|
||||||
|
const order: number[] = []
|
||||||
|
const ready = issues.filter((i) => indegree.get(i.number) === 0)
|
||||||
|
while (ready.length) {
|
||||||
|
ready.sort(readyRank)
|
||||||
|
const next = ready.shift()!
|
||||||
|
order.push(next.number)
|
||||||
|
for (const dep of dependents.get(next.number)!) {
|
||||||
|
const d = indegree.get(dep)! - 1
|
||||||
|
indegree.set(dep, d)
|
||||||
|
if (d === 0) ready.push(byNumber.get(dep)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.length < issues.length) {
|
||||||
|
// cycle: report the nodes that never became ready
|
||||||
|
const stuck = issues.filter((i) => indegree.get(i.number)! > 0).map((i) => i.number)
|
||||||
|
return { items: [], cycle: stuck }
|
||||||
|
}
|
||||||
|
|
||||||
|
// longest-path (by duration) for critical-path marking
|
||||||
|
const longestThrough = new Map<number, number>() // node → longest chain duration passing through it
|
||||||
|
const before = new Map<number, number>() // longest duration of chain ending at node's start
|
||||||
|
for (const n of order) {
|
||||||
|
const depMax = Math.max(0, ...deps.get(n)!.map((d) => before.get(d)! + durationOf(byNumber.get(d)!)))
|
||||||
|
before.set(n, depMax)
|
||||||
|
}
|
||||||
|
const after = new Map<number, number>()
|
||||||
|
for (const n of [...order].reverse()) {
|
||||||
|
const depMax = Math.max(0, ...dependents.get(n)!.map((d) => after.get(d)! + durationOf(byNumber.get(d)!)))
|
||||||
|
after.set(n, depMax)
|
||||||
|
}
|
||||||
|
let globalMax = 0
|
||||||
|
for (const n of order) {
|
||||||
|
const total = before.get(n)! + durationOf(byNumber.get(n)!) + after.get(n)!
|
||||||
|
longestThrough.set(n, total)
|
||||||
|
globalMax = Math.max(globalMax, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serial layout
|
||||||
|
let cursor = 0
|
||||||
|
const items: ScheduledItem[] = order.map((n, idx) => {
|
||||||
|
const issue = byNumber.get(n)!
|
||||||
|
const dur = durationOf(issue)
|
||||||
|
const start = cursor
|
||||||
|
cursor += dur
|
||||||
|
const blockedBy = deps.get(n)!
|
||||||
|
const blocks = dependents.get(n)!
|
||||||
|
const critical = globalMax > 0 && longestThrough.get(n) === globalMax
|
||||||
|
return {
|
||||||
|
number: n,
|
||||||
|
title: issue.title,
|
||||||
|
labels: issue.labels,
|
||||||
|
order: idx,
|
||||||
|
startDay: start,
|
||||||
|
endDay: cursor,
|
||||||
|
durationDays: dur,
|
||||||
|
blockedBy,
|
||||||
|
blocks,
|
||||||
|
critical,
|
||||||
|
rationale: rationaleFor({ critical, blockedBy, blocks, priority: issue.priority }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { items, cycle: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Focus {
|
||||||
|
now: ScheduledItem | null
|
||||||
|
next: ScheduledItem | null
|
||||||
|
later: ScheduledItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Now/Next/Later = the first three of the plan (already dependency- + priority-ordered). */
|
||||||
|
export function selectFocus(plan: SchedulePlan): Focus {
|
||||||
|
return {
|
||||||
|
now: plan.items[0] ?? null,
|
||||||
|
next: plan.items[1] ?? null,
|
||||||
|
later: plan.items[2] ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user