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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 6.1.9

### Fixed

- Load `proper-lockfile` with a dynamic import inside the functions that take a lock, instead of at module scope.
It pulls in `graceful-fs`, which patches Node's `fs` at import time (`fs.close = ...`).
workerd exposes those as getter-only accessors, so the assignment threw while Cloudflare validated an uploaded Worker (`Cannot set property close of #<Object> which has only a getter [code: 10021]`), rejecting the whole Worker — including consumers that never take a lock.
`verify:package` now fails on any static import of a module that patches a Node builtin, because `wrangler deploy --dry-run` bundles without executing and cannot see this class of failure.

## 6.1.8

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-knowledge",
"version": "6.1.8",
"version": "6.1.9",
"description": "Build, search, evaluate, and improve source-backed knowledge bases.",
"homepage": "https://github.com/tangle-network/agent-knowledge#readme",
"repository": {
Expand Down
48 changes: 48 additions & 0 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
Expand Down Expand Up @@ -44,6 +45,18 @@ const forbiddenDeclarationNames = [
'TwoAgentResearchLoopResult',
'TwoAgentResearchRound',
]
// Packages that patch a Node builtin at MODULE scope. `graceful-fs` assigns
// `fs.close` / `fs.closeSync`; workerd exposes those as getter-only accessors,
// so the assignment throws while Cloudflare validates an uploaded Worker —
// `Cannot set property close of #<Object> which has only a getter [code:
// 10021]` — rejecting the entire Worker with no request frame and no way for
// the app to catch it. A STATIC import of any of these anywhere in the shipped
// bundle poisons every downstream Cloudflare consumer, whether or not it ever
// takes a lock; a dynamic `import()` does not, because the module scope only
// runs when the lock is actually needed. `wrangler deploy --dry-run` bundles
// without executing, so it is structurally blind to this class — this check is
// what stands in for it.
const edgeUnsafeStaticImports = ['proper-lockfile', 'graceful-fs']
const requiredMemoryExports = ['runAgentMemoryImprovement']
const requiredAgentEvalExports = ['gepaOptimizationMethod', 'skillOptOptimizationMethod']
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
Expand Down Expand Up @@ -116,6 +129,7 @@ try {
throw new Error(`published declarations include obsolete export: ${name}`)
}
}
assertNoEdgeUnsafeStaticImports(join(installedPackageDir, 'dist'))
const installedAgentEval = JSON.parse(
readFileSync(
join(appDir, 'node_modules', '@tangle-network', 'agent-eval', 'package.json'),
Expand Down Expand Up @@ -209,6 +223,40 @@ try {
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
function assertNoEdgeUnsafeStaticImports(distDir) {
const offenders = []
for (const file of javascriptFiles(distDir)) {
const source = readFileSync(file, 'utf8')
for (const specifier of edgeUnsafeStaticImports) {
const quoted = `["']${specifier.replaceAll('/', '\\/')}["']`
// A static ESM import, a re-export, or a CJS require. `import(...)` is
// deliberately excluded: deferring the module scope is the whole fix.
const statik = new RegExp(`(?:from\\s*${quoted}|require\\(\\s*${quoted}\\s*\\))`)
if (statik.test(source)) offenders.push(`${file.slice(distDir.length + 1)} -> ${specifier}`)
}
}
if (offenders.length > 0) {
throw new Error(
[
'published bundle statically imports a package that patches a Node builtin at module scope.',
'Cloudflare rejects the whole Worker on upload with code 10021, and a dry run cannot see it.',
'Load it with a dynamic import() inside the function that needs it.',
...offenders.map((offender) => ` - ${offender}`),
].join('\n'),
)
}
}

function javascriptFiles(directory) {
const files = []
for (const entry of readdirSync(directory)) {
const path = join(directory, entry)
if (statSync(path).isDirectory()) files.push(...javascriptFiles(path))
else if (/\.(?:js|mjs|cjs)$/.test(entry)) files.push(path)
}
return files
}

function onlyTarball(directory) {
const tarballs = readdirSync(directory).filter((name) => name.endsWith('.tgz'))
if (tarballs.length !== 1) {
Expand Down
19 changes: 18 additions & 1 deletion src/mutation-lock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { check, type LockOptions, lock } from 'proper-lockfile'
import type { LockOptions } from 'proper-lockfile'
import {
isMissingFile,
withSafeDescendant,
Expand All @@ -19,6 +19,21 @@ const DEFAULT_STALE_MS = 15 * 60 * 1000
const DEFAULT_READ_RETRIES = 100
const DEFAULT_READ_WAIT_MS = 25

// `proper-lockfile` pulls in `graceful-fs`, which patches Node's `fs` at MODULE
// scope (`fs.close = ...`, `fs.closeSync = ...`). workerd exposes those as
// getter-only accessors, so a STATIC import makes the assignment throw while
// Cloudflare runs startup validation on upload — `Cannot set property close of
// #<Object> which has only a getter [code: 10021]` — rejecting the whole Worker
// before any request frame exists, uncatchably, even for bundles that never
// take a lock. Loading it on first use keeps this module importable at the
// edge. Every consumer below is already async, so nothing else changes.
let lockfileModule: Promise<typeof import('proper-lockfile')> | undefined

function loadLockfile(): Promise<typeof import('proper-lockfile')> {
lockfileModule ??= import('proper-lockfile')
return lockfileModule
}

interface KnowledgeMutationScope {
active: boolean
lock: KnowledgeMutationLock
Expand Down Expand Up @@ -289,6 +304,7 @@ export async function acquireDurableFileLock(
const update = Math.max(1_000, Math.floor(stale / 3))
let compromised: Error | undefined
let released = false
const { lock } = await loadLockfile()
await mkdir(dirname(options.lockfilePath), { recursive: true })
const release = await lock(target, {
lockfilePath: options.lockfilePath,
Expand Down Expand Up @@ -429,6 +445,7 @@ async function hasActiveMutationLock(
options: Pick<KnowledgeReadOptions, 'staleMs'>,
): Promise<boolean> {
try {
const { check } = await loadLockfile()
return await withSafeDirectory(root, '.agent-knowledge', false, (cacheDir) =>
check(root, {
lockfilePath: join(cacheDir, 'mutation.lock.durable'),
Expand Down