Skip to content
Merged
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
38 changes: 38 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Root .dockerignore — applies ONLY to root-context image builds. Currently that
# is the taskforge image (.github/workflows/taskforge.yml uses `context: .`).
# The nginxpilot image keeps `context: nginxpilot` + its own .dockerignore, so it
# is unaffected by this file.

# version control / CI
.git
.github

# all workspace dependencies + build outputs — reinstalled/rebuilt inside the image.
# NOTE: only the published packages' build dirs are listed; taskforge/lib is SOURCE
# (the @/lib/* modal+toast helpers) and must NOT be ignored.
**/node_modules
base/lib
logging/lib
serializer/lib
web-components/lib
phaser-plus/lib
node/lib
examples/dist
**/.next
**/*.tsbuildinfo

# taskforge runtime state & secrets — MUST never enter the image
taskforge/.workspace
taskforge/.claude-accounts
**/taskforge.db
**/taskforge.db-*
**/*.db-wal
**/*.db-shm

# env / secrets
**/.env
**/.env*.local

# misc noise
**/npm-debug.log*
**/.DS_Store
23 changes: 20 additions & 3 deletions .github/workflows/taskforge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,23 @@ name: taskforge
on:
push:
branches: [main]
paths: ['taskforge/**', '.github/workflows/taskforge.yml']
# the image bundles built @toolcase/base + web-components, so changes there
# must rebuild taskforge too (build context is the repo root).
paths:
- 'taskforge/**'
- 'base/**'
- 'web-components/**'
- 'package-lock.json'
- '.dockerignore'
- '.github/workflows/taskforge.yml'
pull_request:
paths: ['taskforge/**', '.github/workflows/taskforge.yml']
paths:
- 'taskforge/**'
- 'base/**'
- 'web-components/**'
- 'package-lock.json'
- '.dockerignore'
- '.github/workflows/taskforge.yml'

permissions:
contents: read
Expand Down Expand Up @@ -44,7 +58,10 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: taskforge
# repo root: taskforge's @toolcase/* deps are file:../ workspace
# siblings, so the build needs the whole monorepo (see taskforge/Dockerfile).
context: .
file: taskforge/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
lib
# taskforge uses lib/ as a SOURCE dir (Next.js app helpers), not build output — keep it tracked
!taskforge/lib/
node_modules
.parcel-cache
.DS_Store
Expand Down
80 changes: 42 additions & 38 deletions base/src/BPlusIndex/BPlusIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export class BPlusIndex<K, V> {

static deserializers = {
string: (b: Uint8Array): string => DEC.decode(b),
number: (b: Uint8Array): number => new DataView(b.buffer, b.byteOffset, b.byteLength).getFloat64(0, true),
number: (b: Uint8Array): number => {
const v = new DataView(b.buffer, b.byteOffset, b.byteLength).getFloat64(0, true)
return Object.is(v, -0) ? 0 : v // normalize negative zero to +0
},
bigint: (b: Uint8Array): bigint => {
const view = new DataView(b.buffer, b.byteOffset, b.byteLength)
const magLen = view.getUint16(0, true)
Expand Down Expand Up @@ -838,38 +841,35 @@ export class BPlusIndex<K, V> {
const hasLower = gte !== undefined || gt !== undefined
const lowerKey = gte ?? gt

// Leaf IDs in ascending key order via authoritative tree descent;
// the `nextPageId` sibling chain is not trustworthy after COW.
const leafIds = await this._collectAllLeafIds()

if (hasLower) {
// Locate the leaf that should contain the lower bound, then scan
// it and every subsequent leaf in order.
const { leaf } = await this._findLeaf(lowerKey!)
const startLeafIdx = leafIds.indexOf(leaf.pageId)
const pos = this._lowerBound(leaf.entries.map(e => e.keyRaw), lowerKey!)
let startIdx = pos
if (gt !== undefined && startIdx < leaf.entries.length) {
if (this.compare(this.deserializeKey(leaf.entries[startIdx].keyRaw), gt) === 0) startIdx++
let startEntry = pos
if (gt !== undefined && startEntry < leaf.entries.length) {
if (this.compare(this.deserializeKey(leaf.entries[startEntry].keyRaw), gt) === 0) startEntry++
}

for (let i = startIdx; i < leaf.entries.length; i++) {
const k = this.deserializeKey(leaf.entries[i].keyRaw)
if (lte !== undefined && this.compare(k, lte) > 0) return
if (lt !== undefined && this.compare(k, lt) >= 0) return
yield [k, this.deserializeValue(leaf.entries[i].valueRaw)]
if (++yielded === limit) return
}

let nextId = leaf.nextPageId
while (nextId !== NULL_PAGE) {
const nl = await this._readLeaf(nextId)
for (const e of nl.entries) {
const k = this.deserializeKey(e.keyRaw)
for (let li = Math.max(startLeafIdx, 0); li < leafIds.length; li++) {
const cur = li === startLeafIdx ? leaf : await this._readLeaf(leafIds[li])
const from = li === startLeafIdx ? startEntry : 0
for (let i = from; i < cur.entries.length; i++) {
const k = this.deserializeKey(cur.entries[i].keyRaw)
if (lte !== undefined && this.compare(k, lte) > 0) return
if (lt !== undefined && this.compare(k, lt) >= 0) return
yield [k, this.deserializeValue(e.valueRaw)]
yield [k, this.deserializeValue(cur.entries[i].valueRaw)]
if (++yielded === limit) return
}
nextId = nl.nextPageId
}
} else {
// Full scan from leftmost leaf
let pageId = await this._leftmostLeafId()
while (pageId !== NULL_PAGE) {
// Full scan in ascending leaf order
for (const pageId of leafIds) {
const leaf = await this._readLeaf(pageId)
for (const e of leaf.entries) {
const k = this.deserializeKey(e.keyRaw)
Expand All @@ -878,12 +878,11 @@ export class BPlusIndex<K, V> {
yield [k, this.deserializeValue(e.valueRaw)]
if (++yielded === limit) return
}
pageId = leaf.nextPageId
}
}
} else {
// ── reverse scan ─────────────────────────────────────────────────
// Collect all leaf IDs by forward scan (avoids stale prevPageId links)
// Collect all leaf IDs via tree descent (avoids stale prev/next links)
const leafIds = await this._collectAllLeafIds()
for (let li = leafIds.length - 1; li >= 0; li--) {
const leaf = await this._readLeaf(leafIds[li])
Expand All @@ -900,24 +899,29 @@ export class BPlusIndex<K, V> {
}
}

private async _leftmostLeafId(): Promise<number> {
let pageId = this.rootPageId
while (true) {
/**
* Gather every leaf page ID in ascending key order by descending the tree
* via internal-node children (which are kept authoritative on every COW
* split/update). The leaf `nextPageId` sibling chain is NOT used: a leaf
* that is copied or split is relocated to a fresh page and freed, but its
* left sibling's `nextPageId` is intentionally not rewritten (see
* `_splitLeaf`), so that chain can dangle to a freed/reused page.
*/
private async _collectAllLeafIds(): Promise<number[]> {
const ids: number[] = []

const descend = async (pageId: number): Promise<void> => {
const raw = await this.adapter.read(pageId)
if (!raw || raw[4] === PAGE_TYPE_LEAF) return pageId
if (!raw) throw new Error(`BPlusIndex: missing page ${pageId}`)
if (raw[4] === PAGE_TYPE_LEAF) {
ids.push(pageId)
return
}
const node = await this._readInternal(pageId)
pageId = node.children[0]
for (const childId of node.children) await descend(childId)
}
}

private async _collectAllLeafIds(): Promise<number[]> {
const ids: number[] = []
let pageId = await this._leftmostLeafId()
while (pageId !== NULL_PAGE) {
ids.push(pageId)
const leaf = await this._readLeaf(pageId)
pageId = leaf.nextPageId
}
await descend(this.rootPageId)
return ids
}

Expand Down
2 changes: 1 addition & 1 deletion base/src/Vec2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class Vec2 {
}

negate(): Vec2 {
return new Vec2(-this.x, -this.y)
return new Vec2(this.x === 0 ? 0 : -this.x, this.y === 0 ? 0 : -this.y)
}

distanceTo(other: Vec2): number {
Expand Down
1 change: 1 addition & 0 deletions base/src/async/throttle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function throttle<T extends ThrottleFn>(fn: T, ms: number): ThrottledFn<T> {
const now = Date.now()
const remaining = ms - (now - lastCall)
lastArgs = args
// eslint-disable-next-line @typescript-eslint/no-this-alias -- preserve caller context for deferred apply()
lastCtx = this

if (remaining <= 0 || remaining > ms) {
Expand Down
2 changes: 1 addition & 1 deletion base/src/formatByteSize.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const formatByteSize = (bytes: number, decimals: number = 2): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes'
if (!Number.isFinite(bytes) || bytes < 1) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
Expand Down
4 changes: 2 additions & 2 deletions base/src/packing/MaxRects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ class MaxRects extends Algorithm {
const shortLeftover = Math.min(leftoverHorizontal, leftoverVertical)
const longLeftover = Math.max(leftoverHorizontal, leftoverVertical)

let score1 = 0
let score2 = 0
let score1: number
let score2: number
switch (this.heuristic) {
case 'best-short-side-fit':
score1 = shortLeftover
Expand Down
2 changes: 1 addition & 1 deletion base/src/slugify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ function slugify(input: string): string {
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
Expand Down
5 changes: 4 additions & 1 deletion base/src/spatial/SpatialHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,17 @@ class SpatialHash<T> {
}

nearest(point: SpatialPoint, maxDist = Infinity): T | null {
if (this._size === 0) return null
const { cs } = this
const cx0 = Math.floor(point.x / cs)
const cy0 = Math.floor(point.y / cs)
let best: T | null = null
let bestDist = maxDist
const seen = new Set<T>()
for (let r = 0; r <= 1e4; r++) {
if (best !== null && (r - 1) * cs >= bestDist) break
// Once the nearest cell edge in this ring is beyond the best distance
// found so far (or the maxDist cap), no closer item can exist.
if ((r - 1) * cs >= bestDist) break
if (r === 0) {
this.scanCell(cx0, cy0, point, seen, (item, d) => {
if (d < bestDist) { bestDist = d; best = item }
Expand Down
2 changes: 1 addition & 1 deletion base/test/BPlusIndex.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest'

Check warning on line 1 in base/test/BPlusIndex.test.ts

View workflow job for this annotation

GitHub Actions / quality

'beforeEach' is defined but never used. Allowed unused vars must match /^_/u
import { BPlusIndex, MemoryAdapter } from '../src/BPlusIndex'

// ── helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -287,7 +287,7 @@
it('number round-trips', () => {
for (const n of [0, -1, 1.5, Math.PI, Number.MAX_SAFE_INTEGER, -0]) {
const b = BPlusIndex.serializers.number(n)
expect(BPlusIndex.deserializers.number(b)).toBe(n === -0 ? 0 : n)
expect(BPlusIndex.deserializers.number(b)).toBe(Object.is(n, -0) ? 0 : n)
}
})

Expand Down Expand Up @@ -841,7 +841,7 @@
})

it('floor returns undefined when target is below all keys', async () => {
const idx = await evenKeys()

Check warning on line 844 in base/test/BPlusIndex.test.ts

View workflow job for this annotation

GitHub Actions / quality

'idx' is assigned a value but never used. Allowed unused vars must match /^_/u
// All keys are >= 0; querying below 0 → undefined
const idx2 = await numIdx()
for (const v of [5, 10, 15]) await idx2.set(v, v)
Expand Down
9 changes: 5 additions & 4 deletions base/test/Color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,11 @@ describe('Color — WCAG contrast utilities', () => {
expect(Color.luminance('#000000')).toBeCloseTo(0.0, 5)
})

it('mid-grey #767676 has luminance ~0.2158', () => {
// WCAG reference: relative luminance of #767676 ≈ 0.2158
// linearC ≈ 0.2158 for equal R, G, B channels at 118/255
expect(Color.luminance('#767676')).toBeCloseTo(0.2158, 3)
it('mid-grey #767676 has luminance ~0.1812', () => {
// WCAG reference: relative luminance of #767676 ≈ 0.1812
// 118/255 = 0.46275 → linearized = ((0.46275 + 0.055) / 1.055)^2.4 ≈ 0.1812
// (equal R, G, B channels, so luminance == the linearized channel value)
expect(Color.luminance('#767676')).toBeCloseTo(0.1812, 3)
})

it('accepts 3-digit shorthand hex', () => {
Expand Down
3 changes: 2 additions & 1 deletion base/test/DisjointSet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ describe('DisjointSet', () => {
ds.union('2', '3')
ds.union('4', '5')
ds.union('0', '2')
expect(ds.count).toBe(7)
// 10 singletons, 4 distinct-root unions → 6 components: {0,1,2,3},{4,5},{6},{7},{8},{9}
expect(ds.count).toBe(6)
expect(ds.connected('0', '3')).toBe(true)
expect(ds.connected('0', '4')).toBe(false)
expect(ds.connected('6', '7')).toBe(false)
Expand Down
31 changes: 20 additions & 11 deletions base/test/PageCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,12 @@ describe('PageCache — LRU eviction', () => {
await cache.read(3)
expect(spy.readCount).toBe(1)

// Page 2 was evicted
await cache.read(2)
expect(spy.readCount).toBe(2)

// Page 1 is still cached
// Page 1 was refreshed by the touch, so it survived eviction → still cached
await cache.read(1)
expect(spy.readCount).toBe(1)

// Page 2 was the LRU victim → no longer cached
await cache.read(2)
expect(spy.readCount).toBe(2)
})
})
Expand Down Expand Up @@ -268,18 +268,26 @@ describe('PageCache + FsAdapter — transparent composition', () => {

it('cache reduces adapter reads on repeated lookups', async () => {
const spy = new SpyAdapter()
const cache = new PageCache(spy, 32)

const idx = await BPlusIndex.open<string, string>({
// Build the data. The write-through cache used while writing warms itself,
// so to measure a genuine cold→hot transition we reopen with a fresh
// (cold) cache over the same underlying storage for the read passes.
const writer = await BPlusIndex.open<string, string>({
...strOpts,
adapter: cache,
adapter: new PageCache(spy, 32),
})

for (let i = 0; i < 10; i++) {
await idx.set(`k${i}`, `v${i}`)
await writer.set(`k${i}`, `v${i}`)
}
await writer.close()

const cache = new PageCache(spy, 32)
const idx = await BPlusIndex.open<string, string>({
...strOpts,
adapter: cache,
})

// First pass: cold reads
// First pass: cold reads (fresh cache must fetch pages from the adapter)
spy.readCount = 0
for (let i = 0; i < 10; i++) {
await idx.get(`k${i}`)
Expand All @@ -293,6 +301,7 @@ describe('PageCache + FsAdapter — transparent composition', () => {
}
const hotReads = spy.readCount

expect(coldReads).toBeGreaterThan(0)
expect(hotReads).toBeLessThan(coldReads)
})
})
6 changes: 3 additions & 3 deletions base/test/Stopwatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('Stopwatch', () => {
})

it('starts not running with zero elapsed', () => {
let t = 0
const t = 0
const sw = new Stopwatch(() => t)
expect(sw.running).toBe(false)
expect(sw.elapsed).toBe(0)
Expand Down Expand Up @@ -47,7 +47,7 @@ describe('Stopwatch', () => {
})

it('stop is a no-op when not running', () => {
let t = 0
const t = 0
const sw = new Stopwatch(() => t)
sw.stop()
expect(sw.elapsed).toBe(0)
Expand Down Expand Up @@ -125,7 +125,7 @@ describe('Stopwatch', () => {
})

it('start, stop, reset all return this', () => {
let t = 0
const t = 0
const sw = new Stopwatch(() => t)
expect(sw.start()).toBe(sw)
expect(sw.stop()).toBe(sw)
Expand Down
Loading
Loading