Skip to content
Closed
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
168 changes: 168 additions & 0 deletions src/storage/file-run-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

function createRunId() {
return `run_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
}

function normalizeLimit(limit, fallback = 20, max = 200) {
const parsed = Number.parseInt(limit, 10)
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
return Math.min(parsed, max)
}

function toAuditEvent(event) {
return {
type: event.type || 'unknown',
timestamp: event.timestamp || new Date().toISOString(),
agentId: event.agentId || null,
agent: event.agent || null,
cost: event.cost || null,
paidVia: event.paidVia || null,
txHash: event.txHash || null,
explorerUrl: event.explorerUrl || null,
status: event.status || null,
totalSpent: event.totalSpent || null,
reason: event.reason || null,
}
}

/**
* FileRunHistoryStore — durable run history persisted to a JSON file.
*
* Survives process restarts. Uses atomic file writes (write tmp -> rename)
* to prevent corruption from concurrent or interrupted writes.
*/
export class FileRunHistoryStore {
constructor({ filePath, maxRuns = 200 }) {
this.filePath =

Check failure on line 42 in src/storage/file-run-history.js

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎······filePath·||⏎·····` with `·filePath·||`
filePath ||
path.join(__dirname, '..', '..', 'data', 'run-history.json')
this.maxRuns = Math.max(10, maxRuns)
this.runs = []
}

async init() {
try {
const dir = path.dirname(this.filePath)
await fs.mkdir(dir, { recursive: true })
const raw = await fs.readFile(this.filePath, 'utf-8')
const parsed = JSON.parse(raw)
this.runs = Array.isArray(parsed) ? parsed : []
console.log(

Check failure on line 56 in src/storage/file-run-history.js

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎········`[run-history]·Loaded·${this.runs.length}·runs·from·${this.filePath}`⏎······` with ``[run-history]·Loaded·${this.runs.length}·runs·from·${this.filePath}``
`[run-history] Loaded ${this.runs.length} runs from ${this.filePath}`
)
} catch (err) {
if (err.code === 'ENOENT') {
this.runs = []
console.log('[run-history] No existing history file, starting fresh')
} else {
console.error(`[run-history] Failed to load: ${err.message}`)
this.runs = []
}
}
}

async _persist() {
const dir = path.dirname(this.filePath)
await fs.mkdir(dir, { recursive: true })
const tmp = `${this.filePath}.tmp`
await fs.writeFile(

Check failure on line 74 in src/storage/file-run-history.js

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎······tmp,⏎······JSON.stringify(this.runs,·null,·2),⏎······'utf-8'⏎····` with `tmp,·JSON.stringify(this.runs,·null,·2),·'utf-8'`
tmp,
JSON.stringify(this.runs, null, 2),
'utf-8'
)
await fs.rename(tmp, this.filePath)
}

async createRun({ task, budget, source }) {
const now = new Date().toISOString()
const run = {
id: createRunId(),
task,
budget,
source: source || 'api',
status: 'running',
createdAt: now,
updatedAt: now,
completedAt: null,
summary: null,
events: [],
txProofs: [],
}
this.runs.unshift(run)
this.runs = this.runs.slice(0, this.maxRuns)
await this._persist()
return run
}

async appendEvent(runId, event) {
const run = this.runs.find((e) => e.id === runId)
if (!run) return
run.events.push(toAuditEvent(event))
run.updatedAt = new Date().toISOString()
await this._persist()
}

async completeRun(runId, result) {
const run = this.runs.find((e) => e.id === runId)
if (!run) return
const txProofs = (result.payments || [])
.filter((p) => p.paymentSuccess)
.map((p) => ({
method: p.paidVia || p.paymentMethod || 'unknown',
txHash: p.txHash || null,
explorerUrl: p.explorerUrl || null,
}))
run.status = 'completed'
run.completedAt = new Date().toISOString()
run.updatedAt = run.completedAt
run.summary = {
totalSpent: result.totalSpent,
budget: result.budget,
budgetExhausted: result.budgetExhausted,
paymentProtocol: result.paymentProtocol,
txCount: result.txCount,
x402PaymentCount: result.x402PaymentCount,
xlmFallbackCount: result.xlmFallbackCount,
unpaidCount: result.unpaidCount,
elapsed: result.elapsed,
}
run.txProofs = txProofs
await this._persist()
}

async failRun(runId, err) {
const run = this.runs.find((e) => e.id === runId)
if (!run) return
run.status = 'failed'
run.completedAt = new Date().toISOString()
run.updatedAt = run.completedAt
run.summary = { error: err?.message || 'unknown error' }
await this._persist()
}

async listRecent(limit = 20) {
return this.runs.slice(0, normalizeLimit(limit, 20, this.maxRuns))
}

async getRun(id) {
return this.runs.find((e) => e.id === id) || null
}
}

/**
* Factory that creates a FileRunHistoryStore from the standard config shape.
*/
export async function createFileRunHistoryStore(cfg) {
const store = new FileRunHistoryStore({
filePath: cfg.runHistoryFile,
maxRuns: cfg.runHistoryMaxRuns,
})
await store.init()
return store
}
2 changes: 1 addition & 1 deletion src/storage/run-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
return store
}

const store = new FileRunHistoryStore(config.runHistoryFile, config.runHistoryMaxRuns)
const store = new FileRunHistoryStore({ filePath: config.runHistoryFile, maxRuns: config.runHistoryMaxRuns })

Check failure on line 168 in src/storage/run-history.js

View workflow job for this annotation

GitHub Actions / lint

Replace `·filePath:·config.runHistoryFile,·maxRuns:·config.runHistoryMaxRuns` with `⏎····filePath:·config.runHistoryFile,⏎····maxRuns:·config.runHistoryMaxRuns,⏎·`
await store.init()
return store
}
Loading