Compare commits
6 Commits
feat/memor
...
feat/offli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8f1184e6 | ||
| c0dd9e70ef | |||
| 842661c9a9 | |||
| 717dc7348f | |||
|
|
f08c4935dc | ||
|
|
e5ce3fe87a |
@@ -276,6 +276,7 @@ describe('buildProjectView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
|
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
|
||||||
expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' })
|
expect(buildProjectView('milestone', undefined, snap, asOf)).toEqual({ notImplemented: 'milestone' })
|
||||||
|
expect(buildProjectView('runway', undefined, snap, asOf)).toEqual({ notImplemented: 'runway' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type {
|
|||||||
|
|
||||||
export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js'
|
export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js'
|
||||||
export type { CacheDriver } from './cache/cache-v0.js'
|
export type { CacheDriver } from './cache/cache-v0.js'
|
||||||
|
export { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue } from './queue/write-queue-v0.js'
|
||||||
|
export type { QueuedWrite } from './queue/write-queue-v0.js'
|
||||||
export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
|
export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
|
||||||
export type {
|
export type {
|
||||||
ChangeProposal,
|
ChangeProposal,
|
||||||
|
|||||||
75
packages/core/src/perf/perf.test.ts
Normal file
75
packages/core/src/perf/perf.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Performance pass (#32). The deterministic compute path must stay well under the
|
||||||
|
* PLAN.md targets on representative fixtures:
|
||||||
|
* - scheduler + Monte Carlo forecast < 1s @ 200 open issues.
|
||||||
|
* - scaling stays roughly linear (no accidental O(n²) in the hot path).
|
||||||
|
*
|
||||||
|
* Reconcile-<5s@500 is network-bound (~2N gitea calls) and is covered by the live
|
||||||
|
* reconcile, not here — this file benchmarks the pure compute the app runs each
|
||||||
|
* turn. Bounds are the actual targets with comfortable headroom so timing jitter
|
||||||
|
* can't flake the suite; actuals are logged.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { forecast } from '../forecast/forecast-v0.js'
|
||||||
|
import { type DependencyEdge, schedule, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
|
||||||
|
import { scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
|
||||||
|
|
||||||
|
const EST = [1, 2, 3, 5, 8]
|
||||||
|
const WORKERS: Worker[] = [
|
||||||
|
{ person: 'a', speed: 0.8 },
|
||||||
|
{ person: 'b', speed: 0.6 },
|
||||||
|
{ person: 'c', speed: 1.0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A representative open backlog: varied estimates/priorities/assignees + a light dependency web. */
|
||||||
|
function backlog(n: number): { issues: SchedulableIssue[]; edges: DependencyEdge[] } {
|
||||||
|
const issues: SchedulableIssue[] = Array.from({ length: n }, (_, i) => ({
|
||||||
|
number: i + 1,
|
||||||
|
title: `Issue ${i + 1} with a representative title of some length`,
|
||||||
|
labels: [`est/${EST[i % EST.length]}d`, `p/${(i % 4) + 1}`],
|
||||||
|
estimateDays: EST[i % EST.length],
|
||||||
|
priority: (i % 4) + 1,
|
||||||
|
assignee: WORKERS[i % WORKERS.length].person,
|
||||||
|
}))
|
||||||
|
// ~1 dependency per 3 issues, always on a lower-numbered issue (acyclic)
|
||||||
|
const edges: DependencyEdge[] = []
|
||||||
|
for (let i = 3; i < n; i += 3) edges.push({ issue: i + 1, dependsOn: i - 1 })
|
||||||
|
return { issues, edges }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ms(fn: () => void): number {
|
||||||
|
const t0 = performance.now()
|
||||||
|
fn()
|
||||||
|
return performance.now() - t0
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('perf (#32)', () => {
|
||||||
|
it('scheduler + Monte Carlo forecast < 1s @ 200 open issues', () => {
|
||||||
|
const { issues, edges } = backlog(200)
|
||||||
|
const elapsed = ms(() => {
|
||||||
|
schedule(issues, edges)
|
||||||
|
scheduleWithCapacity(issues, edges, WORKERS)
|
||||||
|
forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default)
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[perf] schedule+capacity+forecast @200 = ${elapsed.toFixed(1)}ms`)
|
||||||
|
expect(elapsed).toBeLessThan(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales roughly linearly — 400 issues is well under 4x the 100-issue time', () => {
|
||||||
|
const small = backlog(100)
|
||||||
|
const big = backlog(400)
|
||||||
|
const run = (b: typeof small) => () => {
|
||||||
|
schedule(b.issues, b.edges)
|
||||||
|
forecast(b.issues, b.edges, { workers: WORKERS })
|
||||||
|
}
|
||||||
|
// warm up (JIT) so the ratio reflects steady state
|
||||||
|
run(small)()
|
||||||
|
const t100 = Math.max(ms(run(small)), 0.1)
|
||||||
|
const t400 = ms(run(big))
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[perf] @100 = ${t100.toFixed(1)}ms · @400 = ${t400.toFixed(1)}ms · ratio ${(t400 / t100).toFixed(1)}x`)
|
||||||
|
expect(t400).toBeLessThan(t100 * 8) // generous: rules out O(n²), tolerant of jitter
|
||||||
|
})
|
||||||
|
})
|
||||||
67
packages/core/src/queue/write-queue-v0.test.ts
Normal file
67
packages/core/src/queue/write-queue-v0.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { IssueChange } from '../changes/apply-changes-v0.js'
|
||||||
|
import { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue, type QueuedWrite } from './write-queue-v0.js'
|
||||||
|
|
||||||
|
const reest = (issue: number, estimate: string): IssueChange =>
|
||||||
|
({ kind: 'reestimate', issue, estimate }) as IssueChange
|
||||||
|
const assign = (issue: number, assignee: string | null): IssueChange => ({ kind: 'assign', issue, assignee })
|
||||||
|
|
||||||
|
describe('write-queue-v0 (#33)', () => {
|
||||||
|
it('coalesces repeat writes to the same (issue, axis) — replay applies the latest once', () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), '2026-02-10T09:00:00Z')
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/5d'), '2026-02-10T09:05:00Z') // supersedes est/2d
|
||||||
|
q = enqueueWrite(q, assign(7, 'christian'), '2026-02-10T09:06:00Z') // different axis — kept
|
||||||
|
|
||||||
|
expect(q).toHaveLength(2)
|
||||||
|
const pending = pendingWrites(q)
|
||||||
|
expect(pending).toEqual([
|
||||||
|
{ kind: 'reestimate', issue: 7, estimate: 'est/5d' },
|
||||||
|
{ kind: 'assign', issue: 7, assignee: 'christian' },
|
||||||
|
])
|
||||||
|
expect(coalesceKey(reest(7, 'est/2d'))).toBe('7:reestimate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps writes to different issues and axes distinct', () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), 't1')
|
||||||
|
q = enqueueWrite(q, reest(8, 'est/3d'), 't2')
|
||||||
|
q = enqueueWrite(q, assign(8, null), 't3')
|
||||||
|
expect(q).toHaveLength(3)
|
||||||
|
expect(affectedIssues(q)).toEqual([7, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a burst of edits then reconnect lands the final state without duplicating', async () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
// offline: three edits to #7's estimate, one assign
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/1d'), 't1')
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), 't2')
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/8d'), 't3')
|
||||||
|
q = enqueueWrite(q, assign(7, 'stephen'), 't4')
|
||||||
|
|
||||||
|
const apply = vi.fn(async () => ({ ok: true }))
|
||||||
|
const { drained, remaining } = await replayQueue(q, apply)
|
||||||
|
|
||||||
|
// only the final estimate + the assign are applied — not three estimate writes
|
||||||
|
expect(apply).toHaveBeenCalledTimes(2)
|
||||||
|
expect(apply).toHaveBeenNthCalledWith(1, { kind: 'reestimate', issue: 7, estimate: 'est/8d' })
|
||||||
|
expect(apply).toHaveBeenNthCalledWith(2, { kind: 'assign', issue: 7, assignee: 'stephen' })
|
||||||
|
expect(remaining).toHaveLength(0)
|
||||||
|
expect(drained).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps writes that still fail on reconnect queued (never throws)', async () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), 't1')
|
||||||
|
q = enqueueWrite(q, reest(8, 'est/3d'), 't2')
|
||||||
|
// #7 applies, #8 rejects (still unreachable) — and one apply throws
|
||||||
|
const apply = vi.fn(async (c: IssueChange) => {
|
||||||
|
if (c.issue === 8) throw new Error('offline')
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
const { drained, remaining } = await replayQueue(q, apply)
|
||||||
|
expect(drained.map((w) => w.change.issue)).toEqual([7])
|
||||||
|
expect(remaining.map((w) => w.change.issue)).toEqual([8])
|
||||||
|
})
|
||||||
|
})
|
||||||
71
packages/core/src/queue/write-queue-v0.ts
Normal file
71
packages/core/src/queue/write-queue-v0.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Offline write-queue, v0 (#33). While gitea is unreachable, propose-approved
|
||||||
|
* writes are queued instead of lost; on reconnect they replay in order and
|
||||||
|
* resolve against a fresh reconcile. The one hard requirement is *no duplication*:
|
||||||
|
* replaying must not apply the same intent twice.
|
||||||
|
*
|
||||||
|
* The mechanism is coalescing by axis. Every write targets one field of one issue
|
||||||
|
* (its estimate, priority, assignee, or milestone). Queuing a second write to the
|
||||||
|
* same (issue, axis) supersedes the first — only the latest intent survives — so
|
||||||
|
* a burst of edits replays as one final write, and a replay is idempotent (the
|
||||||
|
* apply path no-ops a change already reflected server-side).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IssueChange } from '../changes/apply-changes-v0.js'
|
||||||
|
|
||||||
|
export interface QueuedWrite {
|
||||||
|
change: IssueChange
|
||||||
|
/** ISO time the write was queued (for display + stable ordering). */
|
||||||
|
queuedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coalescing key: one field of one issue. Two writes with the same key are
|
||||||
|
* the same intent expressed twice — the later wins. Each `IssueChange.kind` maps
|
||||||
|
* to exactly one axis, so `issue:kind` is the axis identity.
|
||||||
|
*/
|
||||||
|
export function coalesceKey(change: IssueChange): string {
|
||||||
|
return `${change.issue}:${change.kind}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a write, superseding any pending write to the same (issue, axis). The
|
||||||
|
* superseding write moves to the tail so replay order reflects latest intent.
|
||||||
|
*/
|
||||||
|
export function enqueueWrite(queue: readonly QueuedWrite[], change: IssueChange, queuedAt: string): QueuedWrite[] {
|
||||||
|
const key = coalesceKey(change)
|
||||||
|
return [...queue.filter((w) => coalesceKey(w.change) !== key), { change, queuedAt }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The changes to replay, in order — one per (issue, axis) by construction. */
|
||||||
|
export function pendingWrites(queue: readonly QueuedWrite[]): IssueChange[] {
|
||||||
|
return queue.map((w) => w.change)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct issues touched by the queue — what a post-replay reconcile should re-read. */
|
||||||
|
export function affectedIssues(queue: readonly QueuedWrite[]): number[] {
|
||||||
|
return [...new Set(queue.map((w) => w.change.issue))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay the queue through an apply function (the same guarded write path used
|
||||||
|
* online), in order. Returns the writes that failed (still unreachable / rejected)
|
||||||
|
* so they stay queued; everything else drains. Never throws — a failure is data.
|
||||||
|
*/
|
||||||
|
export async function replayQueue(
|
||||||
|
queue: readonly QueuedWrite[],
|
||||||
|
apply: (change: IssueChange) => Promise<{ ok: boolean }>,
|
||||||
|
): Promise<{ drained: QueuedWrite[]; remaining: QueuedWrite[] }> {
|
||||||
|
const drained: QueuedWrite[] = []
|
||||||
|
const remaining: QueuedWrite[] = []
|
||||||
|
for (const w of queue) {
|
||||||
|
let ok = false
|
||||||
|
try {
|
||||||
|
ok = (await apply(w.change)).ok
|
||||||
|
} catch {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
;(ok ? drained : remaining).push(w)
|
||||||
|
}
|
||||||
|
return { drained, remaining }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user