diff --git a/packages/workflow-util/package.json b/packages/workflow-util/package.json new file mode 100644 index 0000000..db6afd0 --- /dev/null +++ b/packages/workflow-util/package.json @@ -0,0 +1,17 @@ +{ + "name": "@uncaged/workflow-util", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@uncaged/workflow-protocol": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.3" + } +} diff --git a/packages/workflow-util/src/base32.ts b/packages/workflow-util/src/base32.ts new file mode 100644 index 0000000..a8b0c80 --- /dev/null +++ b/packages/workflow-util/src/base32.ts @@ -0,0 +1,80 @@ +import { err, ok, type Result } from "@uncaged/workflow-protocol"; + +/** Crockford Base32 alphabet (no I, L, O, U) — exactly 32 symbols. */ +export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +const DECODE_MAP: Record = (() => { + const map: Record = {}; + for (let i = 0; i < CROCKFORD_BASE32_ALPHABET.length; i++) { + map[CROCKFORD_BASE32_ALPHABET[i]] = i; + } + return map; +})(); + +function padBitCount(bitLength: number): number { + const r = bitLength % 5; + return r === 0 ? 0 : 5 - r; +} + +/** + * Encode an integer using exactly `bitLength` significant bits, MSB-first, + * with the minimum number of leading zero bits so the total is a multiple of 5. + */ +export function encodeCrockfordBase32Bits(value: bigint, bitLength: number): string { + if (bitLength <= 0) { + throw new Error("bitLength must be positive"); + } + const padBits = padBitCount(bitLength); + const totalBits = bitLength + padBits; + const charCount = totalBits / 5; + const shifted = value << BigInt(padBits); + let result = ""; + for (let i = 0; i < charCount; i++) { + const shift = totalBits - 5 * (i + 1); + const quintet = Number((shifted >> BigInt(shift)) & 31n); + result += CROCKFORD_BASE32_ALPHABET[quintet]; + } + return result; +} + +export function decodeCrockfordBase32Bits( + encoded: string, + bitLength: number, +): Result { + if (bitLength <= 0) { + return err(new Error("bitLength must be positive")); + } + const padBits = padBitCount(bitLength); + const totalBits = encoded.length * 5; + if (totalBits !== bitLength + padBits) { + return err(new Error("encoded length does not match bitLength")); + } + let shifted = 0n; + for (let i = 0; i < encoded.length; i++) { + const ch = encoded[i]; + if (ch === undefined) { + return err(new Error("invalid encoded string")); + } + const upper = ch.toUpperCase(); + const val = DECODE_MAP[upper]; + if (val === undefined) { + return err(new Error(`invalid Crockford Base32 character: ${ch}`)); + } + shifted = (shifted << 5n) | BigInt(val & 31); + } + return ok(shifted >> BigInt(padBits)); +} + +/** XXH64-sized value (13 Crockford chars). */ +export function encodeUint64AsCrockford(value: bigint): string { + const masked = value & 0xffff_ffff_ffff_ffffn; + return encodeCrockfordBase32Bits(masked, 64); +} + +export function decodeCrockfordToUint64(encoded: string): Result { + const decoded = decodeCrockfordBase32Bits(encoded, 64); + if (!decoded.ok) { + return decoded; + } + return ok(decoded.value & 0xffff_ffff_ffff_ffffn); +} diff --git a/packages/workflow-util/src/index.ts b/packages/workflow-util/src/index.ts new file mode 100644 index 0000000..b55077c --- /dev/null +++ b/packages/workflow-util/src/index.ts @@ -0,0 +1,13 @@ +export { + CROCKFORD_BASE32_ALPHABET, + decodeCrockfordBase32Bits, + decodeCrockfordToUint64, + encodeCrockfordBase32Bits, + encodeUint64AsCrockford, +} from "./base32.js"; +export { createLogger } from "./logger.js"; +export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js"; +export { ok, err } from "@uncaged/workflow-protocol"; +export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js"; +export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js"; +export { generateUlid } from "./ulid.js"; diff --git a/packages/workflow-util/src/logger.ts b/packages/workflow-util/src/logger.ts new file mode 100644 index 0000000..2305def --- /dev/null +++ b/packages/workflow-util/src/logger.ts @@ -0,0 +1,50 @@ +import { appendFileSync } from "node:fs"; + +import { CROCKFORD_BASE32_ALPHABET } from "./base32.js"; +import type { CreateLoggerOptions, LogFn } from "./types.js"; + +const TAG_LENGTH = 8; + +const TAG_CHAR_SET: ReadonlySet = new Set(CROCKFORD_BASE32_ALPHABET.split("")); + +function assertValidLogTag(tag: string): void { + if (tag.length !== TAG_LENGTH) { + throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`); + } + for (let i = 0; i < tag.length; i++) { + const ch = tag[i]; + if (ch === undefined) { + throw new Error("log tag validation failed"); + } + const upper = ch.toUpperCase(); + if (!TAG_CHAR_SET.has(upper)) { + throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`); + } + } +} + +/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */ +export function createLogger(options: CreateLoggerOptions): LogFn { + if (options.sink.kind === "stderr") { + return (tag: string, content: string) => { + assertValidLogTag(tag); + const line = `${JSON.stringify({ + tag: tag.toUpperCase(), + content, + timestamp: Date.now(), + })}\n`; + process.stderr.write(line); + }; + } + + const filePath = options.sink.path; + return (tag: string, content: string) => { + assertValidLogTag(tag); + const line = `${JSON.stringify({ + tag: tag.toUpperCase(), + content, + timestamp: Date.now(), + })}\n`; + appendFileSync(filePath, line, "utf8"); + }; +} diff --git a/packages/workflow-util/src/refs-field.ts b/packages/workflow-util/src/refs-field.ts new file mode 100644 index 0000000..e89d40c --- /dev/null +++ b/packages/workflow-util/src/refs-field.ts @@ -0,0 +1,22 @@ +/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */ +export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] { + const out = [...refs]; + if (!out.includes(contentHash)) { + out.push(contentHash); + } + return out; +} + +/** Normalize `refs` from persisted JSONL or IPC payloads (missing or invalid → []). */ +export function normalizeRefsField(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const out: string[] = []; + for (const x of value) { + if (typeof x === "string") { + out.push(x); + } + } + return out; +} diff --git a/packages/workflow-util/src/storage-root.ts b/packages/workflow-util/src/storage-root.ts new file mode 100644 index 0000000..f270645 --- /dev/null +++ b/packages/workflow-util/src/storage-root.ts @@ -0,0 +1,13 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Default filesystem root for workflow data (`~/.uncaged/workflow`). */ +export function getDefaultWorkflowStorageRoot(): string { + return join(homedir(), ".uncaged", "workflow"); +} + +/** Global content-addressed store directory under the workflow storage root (`/cas`). */ +export function getGlobalCasDir(storageRoot: string | undefined): string { + const root = storageRoot ?? getDefaultWorkflowStorageRoot(); + return join(root, "cas"); +} diff --git a/packages/workflow-util/src/types.ts b/packages/workflow-util/src/types.ts new file mode 100644 index 0000000..01ca3dd --- /dev/null +++ b/packages/workflow-util/src/types.ts @@ -0,0 +1,9 @@ +export type { Result } from "@uncaged/workflow-protocol"; + +export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string }; + +export type CreateLoggerOptions = { + sink: LoggerSink; +}; + +export type LogFn = (tag: string, content: string) => void; diff --git a/packages/workflow-util/src/ulid.ts b/packages/workflow-util/src/ulid.ts new file mode 100644 index 0000000..60938cf --- /dev/null +++ b/packages/workflow-util/src/ulid.ts @@ -0,0 +1,28 @@ +import { encodeCrockfordBase32Bits } from "./base32.js"; + +const ULID_TIME_BITS = 48; +const ULID_RANDOM_BITS = 80; + +function readRandomUint80(): bigint { + const bytes = new Uint8Array(10); + crypto.getRandomValues(bytes); + let x = 0n; + for (let i = 0; i < bytes.length; i++) { + x = (x << 8n) | BigInt(bytes[i]); + } + return x & ((1n << 80n) - 1n); +} + +/** + * Generate a ULID using Crockford Base32: 10 timestamp chars + 16 random chars. + * Timestamp uses 48 bits of Unix time in milliseconds. + */ +export function generateUlid(nowMs: number): string { + if (!Number.isFinite(nowMs) || nowMs < 0 || nowMs >= 2 ** ULID_TIME_BITS) { + throw new Error("nowMs must be a finite number in [0, 2^48)"); + } + const time = BigInt(Math.floor(nowMs)); + const rand = readRandomUint80(); + const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand; + return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS); +} diff --git a/packages/workflow-util/tsconfig.json b/packages/workflow-util/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/workflow-util/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +}