Execution: reclaim the directories of Ended Tickets after a retention
This commit is contained in:
@@ -245,6 +245,7 @@ Each model request binds the Session to the current prompt revision. The revisio
|
|||||||
| Glue | Translates |
|
| Glue | Translates |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/app/runtime.ts` | The composition root: builds the parts with the interfaces they use, registers the routes and the Source host, and starts and stops dsh-superagent |
|
| `src/app/runtime.ts` | The composition root: builds the parts with the interfaces they use, registers the routes and the Source host, and starts and stops dsh-superagent |
|
||||||
|
| `src/app/reclaim.ts` | Ended Tickets' workspace directories into what may be removed, and the disk they hold into the overview's readout |
|
||||||
| `src/app/notices.ts` | Domain events and worker reports into notices and relays for the main Session |
|
| `src/app/notices.ts` | Domain events and worker reports into notices and relays for the main Session |
|
||||||
| `src/app/tools/main.ts`, `src/app/tools/executor.ts` | Tool arguments of the main Session and the Executor Sessions into calls of the core classes, and their results into cards |
|
| `src/app/tools/main.ts`, `src/app/tools/executor.ts` | Tool arguments of the main Session and the Executor Sessions into calls of the core classes, and their results into cards |
|
||||||
| `src/app/http.ts`, `src/app/views/`, `src/app/api.ts` | HTTP requests into calls, and Store reads into the panel's views; `api.ts` types every route for the panel |
|
| `src/app/http.ts`, `src/app/views/`, `src/app/api.ts` | HTTP requests into calls, and Store reads into the panel's views; `api.ts` types every route for the panel |
|
||||||
@@ -316,6 +317,7 @@ Some generic modules still refer directly to GitHub or Feishu:
|
|||||||
| `src/app/runtime.ts` | The Runtime, the composition root, with its start and stop |
|
| `src/app/runtime.ts` | The Runtime, the composition root, with its start and stop |
|
||||||
| `src/app/setup.ts` | Setup, and `setupChanged`, whether a save changes a field setup reads |
|
| `src/app/setup.ts` | Setup, and `setupChanged`, whether a save changes a field setup reads |
|
||||||
| `src/app/passes.ts` | Passes, the pass scheduler |
|
| `src/app/passes.ts` | Passes, the pass scheduler |
|
||||||
|
| `src/app/reclaim.ts` | `Reclaimer`: hourly scans of the execution root that remove an Ended Ticket's directory once it outlives the retention, and the summary the overview reports |
|
||||||
| `src/app/outbox.ts` | The Outbox: the Engine's `Outbox` port, the receipts and the `reply` effects that send them |
|
| `src/app/outbox.ts` | The Outbox: the Engine's `Outbox` port, the receipts and the `reply` effects that send them |
|
||||||
| `src/app/notices.ts` | The main Session's notices and relays: `eventNotice`, `reportRelay`, `resultRelay` and `followupRelay` |
|
| `src/app/notices.ts` | The main Session's notices and relays: `eventNotice`, `reportRelay`, `resultRelay` and `followupRelay` |
|
||||||
| `src/app/store.ts` | The Store, and `Receipt`, a message of the outbox |
|
| `src/app/store.ts` | The Store, and `Receipt`, a message of the outbox |
|
||||||
@@ -436,7 +438,8 @@ Some generic modules still refer directly to GitHub or Feishu:
|
|||||||
| `tests/runtime-concurrency.spec.ts` | Work that overlaps: Triggers during a pass, Tickets that change during deliveries and launches |
|
| `tests/runtime-concurrency.spec.ts` | Work that overlaps: Triggers during a pass, Tickets that change during deliveries and launches |
|
||||||
| `tests/http.spec.ts` | The same-origin and loopback checks, the status of each kind of failure, the body limit, path parameters and queries |
|
| `tests/http.spec.ts` | The same-origin and loopback checks, the status of each kind of failure, the body limit, path parameters and queries |
|
||||||
| `tests/settings.spec.ts` | Settings saves that race a model or account check, a Policy sync that a save outran, and the refusals of the pause, a sync and a block |
|
| `tests/settings.spec.ts` | Settings saves that race a model or account check, a Policy sync that a save outran, and the refusals of the pause, a sync and a block |
|
||||||
| `tests/views.spec.ts` | What the management views derive from the Store, such as the Thread a dropped message belongs to |
|
| `tests/views.spec.ts` | What the management views derive from the Store, such as the Thread a dropped message belongs to, and the overview's liveness and workspace readouts |
|
||||||
|
| `tests/reclaim.spec.ts` | Which Ended Tickets' directories may be removed: the retention, the execution root, an open Ticket sharing one, and the end times that cannot be read |
|
||||||
| `tests/timeline.spec.ts` | A Thread's pages and a Trigger's neighbours by time and then id, with every received Trigger's time in one form |
|
| `tests/timeline.spec.ts` | A Thread's pages and a Trigger's neighbours by time and then id, with every received Trigger's time in one form |
|
||||||
| `tests/migration.spec.ts` | The schema 11 migration: every stored record in its current shape, and the refusal of older schemas |
|
| `tests/migration.spec.ts` | The schema 11 migration: every stored record in its current shape, and the refusal of older schemas |
|
||||||
| `tests/store.spec.ts` | Repositories and their working sets, in SQLite as in memory, the kv area, with every key of `KV` spelled only in `src/kv.ts` and the migration, settings history and queries |
|
| `tests/store.spec.ts` | Repositories and their working sets, in SQLite as in memory, the kv area, with every key of `KV` spelled only in `src/kv.ts` and the migration, settings history and queries |
|
||||||
|
|||||||
+5
-1
@@ -22,6 +22,7 @@ import type { Store } from './store.ts'
|
|||||||
import type { TextCache } from './cache.ts'
|
import type { TextCache } from './cache.ts'
|
||||||
import type { LinkTarget, ViewHost } from './views/records.ts'
|
import type { LinkTarget, ViewHost } from './views/records.ts'
|
||||||
import type { Variable } from './execution-environment.ts'
|
import type { Variable } from './execution-environment.ts'
|
||||||
|
import type { Reclaimer } from './reclaim.ts'
|
||||||
import { policyHistory } from './settings-history.ts'
|
import { policyHistory } from './settings-history.ts'
|
||||||
import { overview } from './views/overview.ts'
|
import { overview } from './views/overview.ts'
|
||||||
import { settingsView } from './views/settings.ts'
|
import { settingsView } from './views/settings.ts'
|
||||||
@@ -46,6 +47,8 @@ export interface HttpHost {
|
|||||||
readonly masterId: string
|
readonly masterId: string
|
||||||
/** When this process started serving, read by the overview's liveness readout. */
|
/** When this process started serving, read by the overview's liveness readout. */
|
||||||
readonly startedAt: string
|
readonly startedAt: string
|
||||||
|
/** Workspace reclaimer, whose summary the overview reports. */
|
||||||
|
readonly reclaimer: Pick<Reclaimer, 'summary' | 'reclaim'>
|
||||||
/** Resolves once startup finishes or setup determines what is still missing. */
|
/** Resolves once startup finishes or setup determines what is still missing. */
|
||||||
readonly ready: Promise<void>
|
readonly ready: Promise<void>
|
||||||
/** Resolved deployment; throws if read while waiting for setup (`!configured()`). */
|
/** Resolved deployment; throws if read while waiting for setup (`!configured()`). */
|
||||||
@@ -162,7 +165,8 @@ function routes(runtime: HttpHost): Routes {
|
|||||||
return {
|
return {
|
||||||
'GET state': () => stateView(runtime),
|
'GET state': () => stateView(runtime),
|
||||||
'GET overview': () => overview(views(runtime), runtime.health, policyVersion(store.settings()),
|
'GET overview': () => overview(views(runtime), runtime.health, policyVersion(store.settings()),
|
||||||
executorLoad(store, runtime.executor.activeCount()), runtime.setupProblem ?? runtime.feishuProblem, runtime.startedAt),
|
executorLoad(store, runtime.executor.activeCount()), runtime.setupProblem ?? runtime.feishuProblem, runtime.startedAt,
|
||||||
|
runtime.reclaimer.summary),
|
||||||
'GET search': ({ query }) => search(views(runtime), query.q ?? '', runtime.sourceLinks),
|
'GET search': ({ query }) => search(views(runtime), query.q ?? '', runtime.sourceLinks),
|
||||||
'GET sources': () => sourcesStatus(views(runtime), runtime.health, setupState(runtime)),
|
'GET sources': () => sourcesStatus(views(runtime), runtime.health, setupState(runtime)),
|
||||||
'GET settings': () => settingsView(store, runtime.sources, runtime.health, setupState(runtime), runtime.machine.stateDir),
|
'GET settings': () => settingsView(store, runtime.sources, runtime.health, setupState(runtime), runtime.machine.stateDir),
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Reclaims the workspace directories of Tickets that ended long ago.
|
||||||
|
* A Ticket's directory holds whatever its worker cloned and wrote, so it is never deleted while the Ticket is open or can
|
||||||
|
* still be resumed soon; only directories the Executor itself named under the execution root are candidates, so an operator's
|
||||||
|
* own directory (a hand-made worktree, a shared checkout) is never touched.
|
||||||
|
*/
|
||||||
|
import { readdir, rm, stat } from 'node:fs/promises'
|
||||||
|
import { basename, join, resolve, sep } from 'node:path'
|
||||||
|
import type { Ticket } from '../core/index.ts'
|
||||||
|
import { liveTicket } from '../core/index.ts'
|
||||||
|
import type { Machine } from '../deployment.ts'
|
||||||
|
import type { Store } from './store.ts'
|
||||||
|
|
||||||
|
/** How often the reclaimer rescans the execution root, and removes what has outlived the retention. */
|
||||||
|
const SCAN_INTERVAL_MS = 60 * 60_000
|
||||||
|
const DAY_MS = 86_400_000
|
||||||
|
|
||||||
|
export interface ReclaimHost {
|
||||||
|
readonly store: Pick<Store, 'tickets' | 'settings'>
|
||||||
|
readonly machine: Pick<Machine, 'stateDir'>
|
||||||
|
readonly lifetime: AbortSignal
|
||||||
|
closing(): boolean
|
||||||
|
}
|
||||||
|
/** What the panel shows about the execution root: how much of it the Ended Tickets still hold, and when it last ran. */
|
||||||
|
export interface ReclaimSummary {
|
||||||
|
/** The execution root the Executor writes workspaces under. */
|
||||||
|
root: string
|
||||||
|
/** Days an ended Ticket keeps its directory; `0` keeps them forever. */
|
||||||
|
afterDays: number
|
||||||
|
/** Directories under the root that belong to a Ticket, open or ended. */
|
||||||
|
directories: number
|
||||||
|
/** Ended Tickets whose directory has outlived the retention and goes on the next reclaim. */
|
||||||
|
candidates: number
|
||||||
|
/** Bytes those candidates hold. */
|
||||||
|
bytes: number
|
||||||
|
/** When the reclaimer last looked, or undefined before its first scan. */
|
||||||
|
lastScanAt?: string
|
||||||
|
/** Disks the last scan could not read, which it left alone rather than guess at. */
|
||||||
|
unreadable: number
|
||||||
|
}
|
||||||
|
/** A directory the reclaimer may remove, with the Ticket whose workspace it is. */
|
||||||
|
export interface ReclaimCandidate { ticketId: string; directory: string }
|
||||||
|
export interface ReclaimResult { removed: string[]; bytes: number; failed: string[] }
|
||||||
|
|
||||||
|
/** Whether `directory` is a path inside `root`, so removing it can never reach outside the Executor's own tree. */
|
||||||
|
export function within(root: string, directory: string): boolean {
|
||||||
|
const inside = resolve(root), target = resolve(directory)
|
||||||
|
return target.startsWith(inside.endsWith(sep) ? inside : inside + sep)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The directories that may be removed now: a Ticket is ended, its directory is one the Executor named under `root`, no open
|
||||||
|
* Ticket shares it, and it has outlived `afterDays`. A retention of `0` keeps every directory.
|
||||||
|
*/
|
||||||
|
export function reclaimable(tickets: readonly Ticket[], root: string, now: number, afterDays: number): ReclaimCandidate[] {
|
||||||
|
if (afterDays <= 0) return []
|
||||||
|
const open = new Set(tickets.filter(liveTicket).flatMap(ticket => ticket.execution ? [resolve(ticket.execution.directory)] : []))
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return tickets.flatMap(ticket => {
|
||||||
|
const execution = ticket.execution
|
||||||
|
// A Ticket the reclaimer cannot place — never accepted, or ended with no directory — holds nothing to reclaim.
|
||||||
|
if (!execution || !ticket.ended?.at || Number.isNaN(Date.parse(ticket.ended.at))) return []
|
||||||
|
const directory = resolve(execution.directory)
|
||||||
|
if (!within(root, directory) || basename(directory) !== execution.name) return []
|
||||||
|
if (open.has(directory) || seen.has(directory)) return []
|
||||||
|
const age = now - Date.parse(ticket.ended.at)
|
||||||
|
if (age < afterDays * DAY_MS) return []
|
||||||
|
seen.add(directory)
|
||||||
|
return [{ ticketId: ticket.id, directory }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/** Total bytes under `directory`, or `undefined` when it cannot be read. */
|
||||||
|
async function bytesOf(directory: string): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(directory, { recursive: true, withFileTypes: true })
|
||||||
|
let total = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue
|
||||||
|
const file = await stat(join(entry.parentPath, entry.name)).catch(() => undefined)
|
||||||
|
if (file) total += file.size
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
} catch { return undefined }
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Scans the execution root on a timer and removes the directories of Ended Tickets past the retention.
|
||||||
|
* The summary it exposes is what the panel reads, so the page never walks the disk itself.
|
||||||
|
*/
|
||||||
|
export class Reclaimer {
|
||||||
|
private timer?: ReturnType<typeof setInterval>
|
||||||
|
private stopped = false
|
||||||
|
private running?: Promise<ReclaimSummary>
|
||||||
|
private current: ReclaimSummary
|
||||||
|
constructor(private readonly host: ReclaimHost) {
|
||||||
|
this.current = { root: this.root(), afterDays: this.afterDays(), directories: 0, candidates: 0, bytes: 0, unreadable: 0 }
|
||||||
|
}
|
||||||
|
private root(): string {
|
||||||
|
const settings = this.host.store.settings()
|
||||||
|
return settings.executor.directory || join(this.host.machine.stateDir, 'work')
|
||||||
|
}
|
||||||
|
private afterDays(): number { return this.host.store.settings().executor.reclaimAfterDays }
|
||||||
|
/** What the last scan found; the first scan runs at startup, so a page loaded before it reports zeroes. */
|
||||||
|
get summary(): ReclaimSummary { return this.current }
|
||||||
|
/** Scans at startup and then hourly, removing what has outlived the retention. */
|
||||||
|
start(): void {
|
||||||
|
if (this.stopped) return
|
||||||
|
void this.scan().then(found => found.candidates ? this.reclaim() : undefined).catch(() => { /* A scan is best effort. */ })
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.scan().then(found => found.candidates ? this.reclaim() : undefined).catch(() => { /* A scan is best effort. */ })
|
||||||
|
}, SCAN_INTERVAL_MS)
|
||||||
|
this.timer.unref?.()
|
||||||
|
}
|
||||||
|
stop(): Promise<void> {
|
||||||
|
this.stopped = true
|
||||||
|
clearInterval(this.timer)
|
||||||
|
return this.running?.then(() => undefined, () => undefined) ?? Promise.resolve()
|
||||||
|
}
|
||||||
|
/** Counts the directories under the root and what the candidates hold, without removing anything. */
|
||||||
|
scan(): Promise<ReclaimSummary> {
|
||||||
|
return this.running ??= this.scanOnce().finally(() => { this.running = undefined })
|
||||||
|
}
|
||||||
|
private async scanOnce(): Promise<ReclaimSummary> {
|
||||||
|
const root = this.root(), afterDays = this.afterDays(), now = Date.now()
|
||||||
|
const tickets = this.host.store.tickets.list()
|
||||||
|
const directories = tickets.filter(ticket => ticket.execution).flatMap(ticket => ticket.execution ? [ticket.execution] : [])
|
||||||
|
const candidates = reclaimable(tickets, root, now, afterDays)
|
||||||
|
let bytes = 0, unreadable = 0
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const size = await bytesOf(candidate.directory)
|
||||||
|
if (size === undefined) unreadable += 1
|
||||||
|
else bytes += size
|
||||||
|
}
|
||||||
|
this.current = { root, afterDays, directories: directories.length, candidates: candidates.length, bytes, unreadable,
|
||||||
|
lastScanAt: new Date().toISOString() }
|
||||||
|
return this.current
|
||||||
|
}
|
||||||
|
/** Removes every candidate now, whatever the timer's schedule; a directory that cannot be read is left alone and reported. */
|
||||||
|
async reclaim(): Promise<ReclaimResult> {
|
||||||
|
if (this.stopped || this.host.closing() || this.host.lifetime.aborted) return { removed: [], bytes: 0, failed: [] }
|
||||||
|
await this.scan()
|
||||||
|
const root = this.root(), afterDays = this.afterDays()
|
||||||
|
const candidates = reclaimable(this.host.store.tickets.list(), root, Date.now(), afterDays)
|
||||||
|
const removed: string[] = [], failed: string[] = []
|
||||||
|
let bytes = 0
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// Re-check under the current settings: an operator may have changed the root or the retention while this ran.
|
||||||
|
if (!within(this.root(), candidate.directory) || this.afterDays() <= 0) continue
|
||||||
|
const size = await bytesOf(candidate.directory)
|
||||||
|
try {
|
||||||
|
await rm(candidate.directory, { recursive: true, force: true })
|
||||||
|
removed.push(candidate.directory)
|
||||||
|
bytes += size ?? 0
|
||||||
|
} catch { failed.push(candidate.directory) }
|
||||||
|
}
|
||||||
|
await this.scan()
|
||||||
|
return { removed, bytes, failed }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -37,6 +37,7 @@ import { pullEndNotices } from '../sources/github-pull.ts'
|
|||||||
import { HealthBoard } from '../infra/health.ts'
|
import { HealthBoard } from '../infra/health.ts'
|
||||||
import { Outbox } from './outbox.ts'
|
import { Outbox } from './outbox.ts'
|
||||||
import { Passes } from './passes.ts'
|
import { Passes } from './passes.ts'
|
||||||
|
import { Reclaimer } from './reclaim.ts'
|
||||||
import { Setup } from './setup.ts'
|
import { Setup } from './setup.ts'
|
||||||
import { feishuSettings } from '../sources/feishu.ts'
|
import { feishuSettings } from '../sources/feishu.ts'
|
||||||
import { health, type Health, type SourceContext, type SourceHost } from '../sources/host.ts'
|
import { health, type Health, type SourceContext, type SourceHost } from '../sources/host.ts'
|
||||||
@@ -76,6 +77,7 @@ export class Runtime extends Service implements SourceHost {
|
|||||||
readonly masterId: string
|
readonly masterId: string
|
||||||
/** When this process started serving, reported by the overview so a restart is visible rather than inferred. */
|
/** When this process started serving, reported by the overview so a restart is visible rather than inferred. */
|
||||||
readonly startedAt = new Date().toISOString()
|
readonly startedAt = new Date().toISOString()
|
||||||
|
readonly reclaimer: Reclaimer
|
||||||
private readonly sourceLinks = new TextCache(200_000)
|
private readonly sourceLinks = new TextCache(200_000)
|
||||||
private readonly stopped = new AbortController()
|
private readonly stopped = new AbortController()
|
||||||
private readonly disposers: (() => void)[] = []
|
private readonly disposers: (() => void)[] = []
|
||||||
@@ -158,6 +160,7 @@ export class Runtime extends Service implements SourceHost {
|
|||||||
const evaluateDue = () => { engine.evaluateDue(); evals.set(evalsHealth(store)) }
|
const evaluateDue = () => { engine.evaluateDue(); evals.set(evalsHealth(store)) }
|
||||||
const passes = this.passes = new Passes({ engine: { receive: trigger => engine.receive(trigger), evaluateDue }, effects,
|
const passes = this.passes = new Passes({ engine: { receive: trigger => engine.receive(trigger), evaluateDue }, effects,
|
||||||
health: this.runtimeHealth, lifetime: this.lifetime, configured: () => setup.configured(), closing: () => this.closing() })
|
health: this.runtimeHealth, lifetime: this.lifetime, configured: () => setup.configured(), closing: () => this.closing() })
|
||||||
|
this.reclaimer = new Reclaimer({ store, machine, lifetime: this.lifetime, closing: () => this.closing() })
|
||||||
this.sourceDeps = { store, engine, outbox, commands, sources, get ready() { return runtime.ready },
|
this.sourceDeps = { store, engine, outbox, commands, sources, get ready() { return runtime.ready },
|
||||||
get deployment() { return setup.deployment }, configured: () => setup.configured(),
|
get deployment() { return setup.deployment }, configured: () => setup.configured(),
|
||||||
get setupProblem() { return setup.problem }, get feishuProblem() { return setup.feishuProblem }, receive: trigger => passes.receive(trigger) }
|
get setupProblem() { return setup.problem }, get feishuProblem() { return setup.feishuProblem }, receive: trigger => passes.receive(trigger) }
|
||||||
@@ -216,13 +219,14 @@ export class Runtime extends Service implements SourceHost {
|
|||||||
this.executor.resume()
|
this.executor.resume()
|
||||||
this.passes.start()
|
this.passes.start()
|
||||||
this.effects.start()
|
this.effects.start()
|
||||||
|
this.reclaimer.start()
|
||||||
this.runtimeHealth.set({ state: 'ready', detail: 'Orchestrator is ready', at: now() })
|
this.runtimeHealth.set({ state: 'ready', detail: 'Orchestrator is ready', at: now() })
|
||||||
}
|
}
|
||||||
private httpHost(): HttpHost {
|
private httpHost(): HttpHost {
|
||||||
const runtime = this
|
const runtime = this
|
||||||
const { store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks, setup, passes } = this
|
const { store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks, setup, passes } = this
|
||||||
return { context: this.ctx, store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks,
|
return { context: this.ctx, store, sources, prompts, sessions, settings, engine, executor, health, machine, masterId, ready, sourceLinks,
|
||||||
startedAt: this.startedAt, get deployment() { return setup.deployment },
|
startedAt: this.startedAt, reclaimer: this.reclaimer, get deployment() { return setup.deployment },
|
||||||
get setupProblem() { return setup.problem }, get feishuProblem() { return setup.feishuProblem },
|
get setupProblem() { return setup.problem }, get feishuProblem() { return setup.feishuProblem },
|
||||||
configured: () => setup.configured(), links: () => runtime.links(), ticketEnvironment: ticket => runtime.ticketEnvironment(ticket),
|
configured: () => setup.configured(), links: () => runtime.links(), ticketEnvironment: ticket => runtime.ticketEnvironment(ticket),
|
||||||
checkSetup: () => { setup.check() }, requestPass: () => { passes.request() }, models: () => this.ctx.sessionController.modelCatalog() }
|
checkSetup: () => { setup.check() }, requestPass: () => { passes.request() }, models: () => this.ctx.sessionController.modelCatalog() }
|
||||||
@@ -231,12 +235,13 @@ export class Runtime extends Service implements SourceHost {
|
|||||||
stop(): Promise<void> { return this.stopping ??= this.shutdown() }
|
stop(): Promise<void> { return this.stopping ??= this.shutdown() }
|
||||||
private async shutdown(): Promise<void> {
|
private async shutdown(): Promise<void> {
|
||||||
this.closingFlag = true; const passes = this.passes.stop(); this.stopped.abort()
|
this.closingFlag = true; const passes = this.passes.stop(); this.stopped.abort()
|
||||||
|
const reclaiming = this.reclaimer.stop()
|
||||||
try {
|
try {
|
||||||
this.assessor.stop()
|
this.assessor.stop()
|
||||||
const ids = [this.masterId, ...this.store.tickets.list().map(ticket => workerSessionId(ticket.id))]
|
const ids = [this.masterId, ...this.store.tickets.list().map(ticket => workerSessionId(ticket.id))]
|
||||||
const agents = ids.map(id => this.ctx.agents.get(id as SessionId)).filter((agent): agent is Agent => agent !== undefined)
|
const agents = ids.map(id => this.ctx.agents.get(id as SessionId)).filter((agent): agent is Agent => agent !== undefined)
|
||||||
for (const agent of agents) agent.cancel({ kind: 'user' }, { keepInbox: true })
|
for (const agent of agents) agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||||
await Promise.allSettled([passes, this.effects.drain(), ...agents.map(agent => agent.whenIdle())])
|
await Promise.allSettled([passes, reclaiming, this.effects.drain(), ...agents.map(agent => agent.whenIdle())])
|
||||||
// The workers' jobs and subagents stop too: their ends would start worker turns without the tool limits released below.
|
// The workers' jobs and subagents stop too: their ends would start worker turns without the tool limits released below.
|
||||||
await this.executor.shutdown()
|
await this.executor.shutdown()
|
||||||
this.executor.dispose()
|
this.executor.dispose()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type SourceSettings = SettingsVersion['settings']['sources'][string]
|
|||||||
export type SettingsChangeCode = 'ruleAdded' | 'ruleRenamed' | 'ruleInstruction' | 'ruleEnabled' | 'ruleDisabled' | 'ruleRemoved'
|
export type SettingsChangeCode = 'ruleAdded' | 'ruleRenamed' | 'ruleInstruction' | 'ruleEnabled' | 'ruleDisabled' | 'ruleRemoved'
|
||||||
| 'sourceEnabled' | 'sourceDisabled' | 'sourceField' | 'sourceRowsAdded' | 'sourceRowsRemoved' | 'sourceRowsChanged' | 'sourceAdmission'
|
| 'sourceEnabled' | 'sourceDisabled' | 'sourceField' | 'sourceRowsAdded' | 'sourceRowsRemoved' | 'sourceRowsChanged' | 'sourceAdmission'
|
||||||
| 'mode' | 'timeout' | 'evalModel' | 'executorModel' | 'maxWorkers' | 'paused' | 'resumed' | 'executionDirectory' | 'prepareCommand'
|
| 'mode' | 'timeout' | 'evalModel' | 'executorModel' | 'maxWorkers' | 'paused' | 'resumed' | 'executionDirectory' | 'prepareCommand'
|
||||||
| 'blockedCommands'
|
| 'blockedCommands' | 'reclaimAfterDays'
|
||||||
/** A single settings diff entry rendered by the panel using `change.<code>` and field labels from the Source listing. */
|
/** A single settings diff entry rendered by the panel using `change.<code>` and field labels from the Source listing. */
|
||||||
export interface SettingsChange {
|
export interface SettingsChange {
|
||||||
code: SettingsChangeCode; items?: string[]; from?: string | number | boolean; to?: string | number | boolean; name?: string; source?: string
|
code: SettingsChangeCode; items?: string[]; from?: string | number | boolean; to?: string | number | boolean; name?: string; source?: string
|
||||||
@@ -70,6 +70,8 @@ function settingsChanges(before: SettingsVersion['settings'], after: SettingsVer
|
|||||||
if (before.paused !== after.paused) changes.push({ code: after.paused ? 'paused' : 'resumed' })
|
if (before.paused !== after.paused) changes.push({ code: after.paused ? 'paused' : 'resumed' })
|
||||||
if (before.executor.directory !== after.executor.directory) changes.push({ code: 'executionDirectory', from: before.executor.directory, to: after.executor.directory })
|
if (before.executor.directory !== after.executor.directory) changes.push({ code: 'executionDirectory', from: before.executor.directory, to: after.executor.directory })
|
||||||
if (before.executor.prepare !== after.executor.prepare) changes.push({ code: 'prepareCommand' })
|
if (before.executor.prepare !== after.executor.prepare) changes.push({ code: 'prepareCommand' })
|
||||||
|
if (before.executor.reclaimAfterDays !== after.executor.reclaimAfterDays) changes.push({ code: 'reclaimAfterDays',
|
||||||
|
from: before.executor.reclaimAfterDays, to: after.executor.reclaimAfterDays })
|
||||||
if (!same(before.executor.blockedCommands, after.executor.blockedCommands)) changes.push({ code: 'blockedCommands', from: before.executor.blockedCommands.join(' '), to: after.executor.blockedCommands.join(' ') })
|
if (!same(before.executor.blockedCommands, after.executor.blockedCommands)) changes.push({ code: 'blockedCommands', from: before.executor.blockedCommands.join(' '), to: after.executor.blockedCommands.join(' ') })
|
||||||
for (const id of new Set([...Object.keys(before.sources), ...Object.keys(after.sources)])) changes.push(...sourceChanges(id, before.sources[id], after.sources[id], sources))
|
for (const id of new Set([...Object.keys(before.sources), ...Object.keys(after.sources)])) changes.push(...sourceChanges(id, before.sources[id], after.sources[id], sources))
|
||||||
return changes
|
return changes
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Health } from '../../sources/host.ts'
|
|||||||
import type { SetupProblem } from '../../setup.ts'
|
import type { SetupProblem } from '../../setup.ts'
|
||||||
import { anyoneAllowed } from '../../policy.ts'
|
import { anyoneAllowed } from '../../policy.ts'
|
||||||
import type { Store } from '../store.ts'
|
import type { Store } from '../store.ts'
|
||||||
|
import type { ReclaimSummary } from '../reclaim.ts'
|
||||||
import { awaitingEval, dayAgo, newestFirst, ownersOf, snapshot, threadSummary, type ViewHost } from './records.ts'
|
import { awaitingEval, dayAgo, newestFirst, ownersOf, snapshot, threadSummary, type ViewHost } from './records.ts'
|
||||||
import { sourceHealth, type HealthMap } from './sources.ts'
|
import { sourceHealth, type HealthMap } from './sources.ts'
|
||||||
import { ticketRow, type ExecutorLoad } from './tickets.ts'
|
import { ticketRow, type ExecutorLoad } from './tickets.ts'
|
||||||
@@ -52,9 +53,10 @@ export function evalsHealth(store: Store, now = Date.now()): Health {
|
|||||||
* Builds the operator overview: active system problems, tickets awaiting human action, executor load, and 24-hour trigger flow.
|
* Builds the operator overview: active system problems, tickets awaiting human action, executor load, and 24-hour trigger flow.
|
||||||
* @param setup - Setup problem to surface first (where setup stopped, or a Source paused by setup).
|
* @param setup - Setup problem to surface first (where setup stopped, or a Source paused by setup).
|
||||||
* @param startedAt - When this process started serving, for the liveness readout.
|
* @param startedAt - When this process started serving, for the liveness readout.
|
||||||
|
* @param workspace - What the Ended Tickets still hold under the execution root, and when it was last measured.
|
||||||
*/
|
*/
|
||||||
export function overview(host: ViewHost, health: HealthMap, policyVersionValue: string, load: ExecutorLoad, setup?: SetupProblem,
|
export function overview(host: ViewHost, health: HealthMap, policyVersionValue: string, load: ExecutorLoad, setup?: SetupProblem,
|
||||||
startedAt?: string) {
|
startedAt?: string, workspace?: WorkspaceSummary) {
|
||||||
const { store, sources } = host
|
const { store, sources } = host
|
||||||
const view = snapshot(host)
|
const view = snapshot(host)
|
||||||
const settings = store.settings()
|
const settings = store.settings()
|
||||||
@@ -116,6 +118,7 @@ export function overview(host: ViewHost, health: HealthMap, policyVersionValue:
|
|||||||
return {
|
return {
|
||||||
problems,
|
problems,
|
||||||
liveness,
|
liveness,
|
||||||
|
workspace: workspace ?? null,
|
||||||
person: open.filter(ticket => ticketStage(ticket).stage === 'person').map(ticket => ticketRow(view, ticket)),
|
person: open.filter(ticket => ticketStage(ticket).stage === 'person').map(ticket => ticketRow(view, ticket)),
|
||||||
executor: { ...load, maxWorkers: settings.executor.maxWorkers, idle: open.filter(ticket => ticketStage(ticket).stage === 'idle').length },
|
executor: { ...load, maxWorkers: settings.executor.maxWorkers, idle: open.filter(ticket => ticketStage(ticket).stage === 'idle').length },
|
||||||
flow: { received, filtered, triggers: received - filtered, judged: judgement.judged, accepted: judgement.accepted,
|
flow: { received, filtered, triggers: received - filtered, judged: judgement.judged, accepted: judgement.accepted,
|
||||||
@@ -124,3 +127,5 @@ export function overview(host: ViewHost, health: HealthMap, policyVersionValue:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type OverviewView = ReturnType<typeof overview>
|
export type OverviewView = ReturnType<typeof overview>
|
||||||
|
/** What the execution root holds for Ended Tickets, as the overview reports it. */
|
||||||
|
export type WorkspaceSummary = ReclaimSummary
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ export function ExecutionSettings() {
|
|||||||
const { blocks, environment, sources } = useSettings().settings
|
const { blocks, environment, sources } = useSettings().settings
|
||||||
// Keep raw user text (such as separators between blocked commands) in the draft and parse only on save.
|
// Keep raw user text (such as separators between blocked commands) in the draft and parse only on save.
|
||||||
const executor = blocks.executor.value
|
const executor = blocks.executor.value
|
||||||
const saved = { ...executor, maxWorkers: String(executor.maxWorkers), blockedCommands: executor.blockedCommands.join(' ') }
|
const saved = { ...executor, maxWorkers: String(executor.maxWorkers), reclaimAfterDays: String(executor.reclaimAfterDays),
|
||||||
|
blockedCommands: executor.blockedCommands.join(' ') }
|
||||||
const draft = useBlockDraft('executor', saved, blocks.executor.revision,
|
const draft = useBlockDraft('executor', saved, blocks.executor.revision,
|
||||||
value => ({ ...value, maxWorkers: numberOf(value.maxWorkers), blockedCommands: value.blockedCommands.split(/[\s,]+/).filter(Boolean) }))
|
value => ({ ...value, maxWorkers: numberOf(value.maxWorkers), reclaimAfterDays: numberOf(value.reclaimAfterDays),
|
||||||
|
blockedCommands: value.blockedCommands.split(/[\s,]+/).filter(Boolean) }))
|
||||||
const value = draft.value
|
const value = draft.value
|
||||||
const edit = (next: Partial<typeof value>) => draft.edit({ ...value, ...next })
|
const edit = (next: Partial<typeof value>) => draft.edit({ ...value, ...next })
|
||||||
const prepare = useRef<HTMLTextAreaElement>(null)
|
const prepare = useRef<HTMLTextAreaElement>(null)
|
||||||
@@ -114,6 +116,10 @@ export function ExecutionSettings() {
|
|||||||
onChange={event => edit({ prepare: event.target.value })} />
|
onChange={event => edit({ prepare: event.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<EnvironmentVariables groups={environment.variables} sources={sources} onPick={insert} />
|
<EnvironmentVariables groups={environment.variables} sources={sources} onPick={insert} />
|
||||||
|
<Field label={t('reclaimAfterDays')} hint={t('reclaimAfterDaysHint')}>
|
||||||
|
<Input type="number" min={LIMITS.reclaimAfterDays.min} max={LIMITS.reclaimAfterDays.max} required
|
||||||
|
value={value.reclaimAfterDays} onChange={event => edit({ reclaimAfterDays: event.target.value })} />
|
||||||
|
</Field>
|
||||||
<Field label={t('blockedCommands')} hint={t('blockedCommandsHint')}>
|
<Field label={t('blockedCommands')} hint={t('blockedCommandsHint')}>
|
||||||
<Input className="dt-mono" value={value.blockedCommands} placeholder={t('blockedCommandsNone')} spellCheck={false}
|
<Input className="dt-mono" value={value.blockedCommands} placeholder={t('blockedCommandsNone')} spellCheck={false}
|
||||||
onChange={event => edit({ blockedCommands: event.target.value })} />
|
onChange={event => edit({ blockedCommands: event.target.value })} />
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ const TEXT = {
|
|||||||
livenessPolling: ['在轮询', 'Polling'],
|
livenessPolling: ['在轮询', 'Polling'],
|
||||||
livenessNotPolling: ['不在轮询', 'Not polling'],
|
livenessNotPolling: ['不在轮询', 'Not polling'],
|
||||||
livenessNever: ['尚无', 'Never'],
|
livenessNever: ['尚无', 'Never'],
|
||||||
|
workspaceTitle: ['工单目录', 'Ticket directories'],
|
||||||
|
workspaceHeld: ['已结束工单仍占', 'Held by ended Tickets'],
|
||||||
|
workspaceCandidates: ['待删除', 'To remove'],
|
||||||
|
workspaceKeep: ['保留 {days} 天', 'Kept {days} days'],
|
||||||
|
workspaceKeepForever: ['永不删除', 'Kept forever'],
|
||||||
|
workspaceDirCount: ['目录数', 'Directories'],
|
||||||
sourceErrorHint: ['来源会在每次轮询时自动重试。持续出现时检查网络和该来源的登录状态。', 'The Source retries on every poll. If this persists, check the network and the Source\'s sign-in.'],
|
sourceErrorHint: ['来源会在每次轮询时自动重试。持续出现时检查网络和该来源的登录状态。', 'The Source retries on every poll. If this persists, check the network and the Source\'s sign-in.'],
|
||||||
details: ['详情 ›', 'Details ›'],
|
details: ['详情 ›', 'Details ›'],
|
||||||
lastDay: ['过去 24 小时', 'Last 24 hours'],
|
lastDay: ['过去 24 小时', 'Last 24 hours'],
|
||||||
@@ -385,6 +391,9 @@ const TEXT = {
|
|||||||
prepareCommand: ['准备命令', 'Prepare command'],
|
prepareCommand: ['准备命令', 'Prepare command'],
|
||||||
prepareCommandHint: ['新工单的目录建好前,在根目录下用 sh 运行一次,执行环境的变量是它的环境变量,例如用 git worktree 把仓库检出到 $DSH_SUPERAGENT_DIR。留空就是空目录。失败时工单显示失败,并附上命令的输出。', 'Runs once with sh in the root before a new Ticket\'s directory exists, with the execution environment\'s variables as its environment, for example to check a repository out to $DSH_SUPERAGENT_DIR with git worktree. Leave it empty for an empty directory. A failure fails the Ticket with the command\'s output.'],
|
prepareCommandHint: ['新工单的目录建好前,在根目录下用 sh 运行一次,执行环境的变量是它的环境变量,例如用 git worktree 把仓库检出到 $DSH_SUPERAGENT_DIR。留空就是空目录。失败时工单显示失败,并附上命令的输出。', 'Runs once with sh in the root before a new Ticket\'s directory exists, with the execution environment\'s variables as its environment, for example to check a repository out to $DSH_SUPERAGENT_DIR with git worktree. Leave it empty for an empty directory. A failure fails the Ticket with the command\'s output.'],
|
||||||
prepareCommandEmpty: ['留空:空目录', 'Empty: an empty directory'],
|
prepareCommandEmpty: ['留空:空目录', 'Empty: an empty directory'],
|
||||||
|
reclaimAfterDays: ['工单目录保留天数', 'Keep a Ticket directory for'],
|
||||||
|
reclaimAfterDaysHint: ['工单结束后,它的目录(检出、构建产物)保留这么久,之后由编排器删除以腾出磁盘。只删执行器自己按工单短名建的目录,根目录之外的从不删;填 0 表示永不删除。', 'After a Ticket ends, its directory (checkouts, build output) is kept this long, then the orchestrator removes it to free disk. Only directories the Executor named by Ticket short name go; nothing outside the root ever does. 0 keeps every directory.'],
|
||||||
|
reclaimAfterDaysZero: ['0:永不删除', '0: never remove'],
|
||||||
blockedCommands: ['禁止调用的命令', 'Blocked commands'],
|
blockedCommands: ['禁止调用的命令', 'Blocked commands'],
|
||||||
blockedCommandsHint: ['执行器 Session 调用这些命令名时直接失败,例如用你个人账号登录的 CLI。只拦按名字的调用,用绝对路径仍能运行,所以不是安全边界。gh、git、lark-cli 不能禁止。', 'Command names that fail when an Executor Session calls them, such as a CLI signed in to your own account. Only calls by name fail; an absolute path still runs, so this is not a security boundary. gh, git and lark-cli cannot be blocked.'],
|
blockedCommandsHint: ['执行器 Session 调用这些命令名时直接失败,例如用你个人账号登录的 CLI。只拦按名字的调用,用绝对路径仍能运行,所以不是安全边界。gh、git、lark-cli 不能禁止。', 'Command names that fail when an Executor Session calls them, such as a CLI signed in to your own account. Only calls by name fail; an absolute path still runs, so this is not a security boundary. gh, git and lark-cli cannot be blocked.'],
|
||||||
blockedCommandsNone: ['以空格分隔;没有', 'Separated by spaces; none'],
|
blockedCommandsNone: ['以空格分隔;没有', 'Separated by spaces; none'],
|
||||||
@@ -440,6 +449,7 @@ const TEXT = {
|
|||||||
'change.maxWorkers': ['并行工单数:{from} → {to}', 'Concurrent Tickets: {from} → {to}'],
|
'change.maxWorkers': ['并行工单数:{from} → {to}', 'Concurrent Tickets: {from} → {to}'],
|
||||||
'change.executionDirectory': ['执行目录的根目录:{from} → {to}', 'Ticket directory root: {from} → {to}'],
|
'change.executionDirectory': ['执行目录的根目录:{from} → {to}', 'Ticket directory root: {from} → {to}'],
|
||||||
'change.prepareCommand': ['准备命令', 'Prepare command'],
|
'change.prepareCommand': ['准备命令', 'Prepare command'],
|
||||||
|
'change.reclaimAfterDays': ['工单目录保留天数:{from} → {to}', 'Keep a Ticket directory for: {from} → {to}'],
|
||||||
'change.blockedCommands': ['禁止调用的命令:{from} → {to}', 'Blocked commands: {from} → {to}'],
|
'change.blockedCommands': ['禁止调用的命令:{from} → {to}', 'Blocked commands: {from} → {to}'],
|
||||||
configHistory: ['配置历史', 'Configuration history'],
|
configHistory: ['配置历史', 'Configuration history'],
|
||||||
'change.evalModel': ['研判模型', 'Assessor model'],
|
'change.evalModel': ['研判模型', 'Assessor model'],
|
||||||
|
|||||||
+11
-1
@@ -5,7 +5,7 @@ import { Button, Icon } from './controls.tsx'
|
|||||||
import { useAction, usePanel, usePanelData } from './provider.tsx'
|
import { useAction, usePanel, usePanelData } from './provider.tsx'
|
||||||
import { setupPage, settingsRoute, type Route } from './route.ts'
|
import { setupPage, settingsRoute, type Route } from './route.ts'
|
||||||
import { TicketLine } from './rows.tsx'
|
import { TicketLine } from './rows.tsx'
|
||||||
import { duration, healthText, localized, reasonLabel, valueLabel, readableMarkers } from './text.ts'
|
import { bytes, duration, healthText, localized, reasonLabel, valueLabel, readableMarkers } from './text.ts'
|
||||||
import { CheckSetupButton, ConfirmButton, Hint, LoadState, Section, SetupMessage } from './widgets.tsx'
|
import { CheckSetupButton, ConfirmButton, Hint, LoadState, Section, SetupMessage } from './widgets.tsx'
|
||||||
|
|
||||||
function ProblemLine({ problem, onChanged }: { problem: Problem; onChanged: () => Promise<void> }) {
|
function ProblemLine({ problem, onChanged }: { problem: Problem; onChanged: () => Promise<void> }) {
|
||||||
@@ -92,6 +92,16 @@ export function OverviewPage() {
|
|||||||
{stat(t('livenessPolling'), data.liveness.polling ? t('livenessPolling') : t('livenessNotPolling'))}
|
{stat(t('livenessPolling'), data.liveness.polling ? t('livenessPolling') : t('livenessNotPolling'))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
{data.workspace && <Section title={t('workspaceTitle')}>
|
||||||
|
{/* What ended Tickets still hold is invisible in the flow counters and grows only in the disk, so it is read out here. */}
|
||||||
|
<div className="dt-stats">
|
||||||
|
{stat(t('workspaceHeld'), bytes(data.workspace.bytes, t('lang')))}
|
||||||
|
{stat(t('workspaceCandidates'), data.workspace.candidates)}
|
||||||
|
{stat(t('workspaceDirCount'), data.workspace.directories)}
|
||||||
|
{stat(t('reclaimAfterDays'), data.workspace.afterDays === 0 ? t('workspaceKeepForever')
|
||||||
|
: t('workspaceKeep', { days: data.workspace.afterDays }))}
|
||||||
|
</div>
|
||||||
|
</Section>}
|
||||||
<Section title={t('lastDay')} action={more({ page: 'trigger', query: { filter: 'recent' } })}>
|
<Section title={t('lastDay')} action={more({ page: 'trigger', query: { filter: 'recent' } })}>
|
||||||
{/* Received and evaluated counts operate on Triggers rather than Threads or Decisions, so they do not link to filtered views. */}
|
{/* Received and evaluated counts operate on Triggers rather than Threads or Decisions, so they do not link to filtered views. */}
|
||||||
<div className="dt-stats">
|
<div className="dt-stats">
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export function duration(ms: number | null | undefined, lang: string): string {
|
|||||||
if (ms < 86_400_000) return relative.format(-Math.round(ms / 3_600_000), 'hour')
|
if (ms < 86_400_000) return relative.format(-Math.round(ms / 3_600_000), 'hour')
|
||||||
return relative.format(-Math.round(ms / 86_400_000), 'day')
|
return relative.format(-Math.round(ms / 86_400_000), 'day')
|
||||||
}
|
}
|
||||||
|
/** A byte count as a short localized size, for readouts such as how much a directory holds. */
|
||||||
|
export function bytes(value: number | null | undefined, lang: string): string {
|
||||||
|
if (value === null || value === undefined || !Number.isFinite(value) || value <= 0) return '0'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const step = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
|
||||||
|
const size = value / 1024 ** step
|
||||||
|
return `${step === 0 || size >= 100 ? Math.round(size) : size.toFixed(1)} ${units[step]}`
|
||||||
|
}
|
||||||
export function fullTime(value: string | undefined, lang: string): string {
|
export function fullTime(value: string | undefined, lang: string): string {
|
||||||
const date = new Date(value ?? '')
|
const date = new Date(value ?? '')
|
||||||
return Number.isNaN(date.getTime()) ? '' : date.toLocaleString(timeLocale(lang))
|
return Number.isNaN(date.getTime()) ? '' : date.toLocaleString(timeLocale(lang))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const LIMITS = {
|
|||||||
evalTimeoutSeconds: { min: 30, max: 600 },
|
evalTimeoutSeconds: { min: 30, max: 600 },
|
||||||
maxWorkers: { min: 1, max: 16 },
|
maxWorkers: { min: 1, max: 16 },
|
||||||
prepareCommand: 4000,
|
prepareCommand: 4000,
|
||||||
|
reclaimAfterDays: { min: 0, max: 365 },
|
||||||
promptMaxChars: { min: 1000, max: 1_000_000 },
|
promptMaxChars: { min: 1000, max: 1_000_000 },
|
||||||
admissionContextMaxPages: { min: 1, max: 100 },
|
admissionContextMaxPages: { min: 1, max: 100 },
|
||||||
admissionContextTimeoutMs: { min: 1, max: 90_000 },
|
admissionContextTimeoutMs: { min: 1, max: 90_000 },
|
||||||
|
|||||||
+5
-2
@@ -3,6 +3,7 @@
|
|||||||
* Who may originate work is configured per Source in that Source's own identities; the Policy never maps a person across Sources.
|
* Who may originate work is configured per Source in that Source's own identities; the Policy never maps a person across Sources.
|
||||||
*/
|
*/
|
||||||
import { LIMITS } from './limits.ts'
|
import { LIMITS } from './limits.ts'
|
||||||
|
import { isAbsolute } from 'node:path'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { ADMISSION_DEFAULTS, type Admission, type AdmissionCell, type AdmissionRow } from './admission.ts'
|
import { ADMISSION_DEFAULTS, type Admission, type AdmissionCell, type AdmissionRow } from './admission.ts'
|
||||||
import { key, type AssessmentOutcome, type Mandate, type Policy, type PolicyEffect, type SourceScope, type Strength, type Thread, type Ticket,
|
import { key, type AssessmentOutcome, type Mandate, type Policy, type PolicyEffect, type SourceScope, type Strength, type Thread, type Ticket,
|
||||||
@@ -87,13 +88,15 @@ export const EvalConfigSchema = z.object({
|
|||||||
}).strict()
|
}).strict()
|
||||||
const CommandNameSchema = z.string().regex(/^[A-Za-z0-9._-]+$/).refine(name => name !== '.' && name !== '..', 'Not a command name')
|
const CommandNameSchema = z.string().regex(/^[A-Za-z0-9._-]+$/).refine(name => name !== '.' && name !== '..', 'Not a command name')
|
||||||
.refine(name => !WORKER_COMMANDS.includes(name), `${WORKER_COMMANDS.join(', ')} cannot be blocked`)
|
.refine(name => !WORKER_COMMANDS.includes(name), `${WORKER_COMMANDS.join(', ')} cannot be blocked`)
|
||||||
/** Executor settings: worker model, concurrency, ticket directory root (empty defaults to `<stateDir>/work`), `prepare` command, and blocked commands. */
|
/** Executor settings: worker model, concurrency, ticket directory root (empty defaults to `<stateDir>/work`), `prepare` command, blocked commands, and workspace retention. */
|
||||||
export const ExecutorConfigSchema = z.object({
|
export const ExecutorConfigSchema = z.object({
|
||||||
model: RoleModelSchema.nullable().default(null),
|
model: RoleModelSchema.nullable().default(null),
|
||||||
maxWorkers: z.number().int().min(LIMITS.maxWorkers.min).max(LIMITS.maxWorkers.max).default(3),
|
maxWorkers: z.number().int().min(LIMITS.maxWorkers.min).max(LIMITS.maxWorkers.max).default(3),
|
||||||
directory: z.string().trim().refine(path => path === '' || path.startsWith('/'), 'An absolute path, or empty for <stateDir>/work').default(''),
|
directory: z.string().trim().refine(path => path === '' || isAbsolute(path), 'An absolute path, or empty for <stateDir>/work').default(''),
|
||||||
prepare: z.string().max(LIMITS.prepareCommand).default(''),
|
prepare: z.string().max(LIMITS.prepareCommand).default(''),
|
||||||
blockedCommands: z.array(CommandNameSchema).default([]),
|
blockedCommands: z.array(CommandNameSchema).default([]),
|
||||||
|
/** Days an ended Ticket keeps its workspace directory; `0` keeps every directory, so nothing is ever removed. */
|
||||||
|
reclaimAfterDays: z.number().int().min(LIMITS.reclaimAfterDays.min).max(LIMITS.reclaimAfterDays.max).default(14),
|
||||||
}).strict()
|
}).strict()
|
||||||
export const SourceInstanceSchema = z.object({ enabled: z.boolean().default(true), config: z.record(z.string(), z.unknown()).default({}) }).strict()
|
export const SourceInstanceSchema = z.object({ enabled: z.boolean().default(true), config: z.record(z.string(), z.unknown()).default({}) }).strict()
|
||||||
export const SettingsSchema = z.object({
|
export const SettingsSchema = z.object({
|
||||||
|
|||||||
Vendored
+6
@@ -112,6 +112,8 @@ export interface PanelServer {
|
|||||||
failing: Extract<Problem, { kind: 'outgoing' }>['outgoing'][]
|
failing: Extract<Problem, { kind: 'outgoing' }>['outgoing'][]
|
||||||
/** Sources whose poll loop the fixture reports as stopped, with how long ago their last round was. */
|
/** Sources whose poll loop the fixture reports as stopped, with how long ago their last round was. */
|
||||||
stalled: { id: string; title: Localized; ageMs: number; intervalMs: number }[]
|
stalled: { id: string; title: Localized; ageMs: number; intervalMs: number }[]
|
||||||
|
/** What the execution root holds for Ended Tickets; the panel renders it as a readout. */
|
||||||
|
workspace: NonNullable<ApiResponse<'GET overview'>['workspace']>
|
||||||
}
|
}
|
||||||
export let server: PanelServer
|
export let server: PanelServer
|
||||||
|
|
||||||
@@ -204,6 +206,7 @@ const overviewView = (): ApiResponse<'GET overview'> => {
|
|||||||
// The fixture panel renders the readout, so a poll round a few seconds old stands for a running Source.
|
// The fixture panel renders the readout, so a poll round a few seconds old stands for a running Source.
|
||||||
liveness: { startedAt: '2026-09-22T00:00:00Z', uptimeMs: 3_600_000, lastPollAt: new Date(Date.now() - 4_000).toISOString(),
|
liveness: { startedAt: '2026-09-22T00:00:00Z', uptimeMs: 3_600_000, lastPollAt: new Date(Date.now() - 4_000).toISOString(),
|
||||||
pollAgeMs: 4_000, polling: true },
|
pollAgeMs: 4_000, polling: true },
|
||||||
|
workspace: server.workspace,
|
||||||
person: server.person, executor: { running: 2, queued: 0, maxWorkers: server.settings.executor.maxWorkers, idle: 1 },
|
person: server.person, executor: { running: 2, queued: 0, maxWorkers: server.settings.executor.maxWorkers, idle: 1 },
|
||||||
flow: { received: 12, filtered: 2, triggers: 10, judged: 5, accepted: 1, evaluating: 0,
|
flow: { received: 12, filtered: 2, triggers: 10, judged: 5, accepted: 1, evaluating: 0,
|
||||||
dropped: server.dropped.filter(item => item.dropped.at >= dayAgo()).length },
|
dropped: server.dropped.filter(item => item.dropped.at >= dayAgo()).length },
|
||||||
@@ -257,6 +260,9 @@ function initial(): PanelServer {
|
|||||||
const defaults = { revision: 0, previousRevision: null, savedAt: '', source: 'defaults' }
|
const defaults = { revision: 0, previousRevision: null, savedAt: '', source: 'defaults' }
|
||||||
return {
|
return {
|
||||||
health: {}, down: false, pending: 0, opened: [], requests: [], misuse: [], queued: new Set(), dropped: [], failing: [], stalled: [],
|
health: {}, down: false, pending: 0, opened: [], requests: [], misuse: [], queued: new Set(), dropped: [], failing: [], stalled: [],
|
||||||
|
// One ended Ticket outlived the retention, so the readout has something to report.
|
||||||
|
workspace: { root: '/srv/dsh-superagent/work', afterDays: 14, directories: 5, candidates: 1, bytes: 58 * 1024 * 1024,
|
||||||
|
lastScanAt: new Date(Date.now() - 600_000).toISOString(), unreadable: 0 },
|
||||||
setup: { machine: MACHINE, problem: null, feishuProblem: null,
|
setup: { machine: MACHINE, problem: null, feishuProblem: null,
|
||||||
accounts: { repository: 'acme/widgets', agent: 'agent-bot', feishu: { appId: 'cli_bot', brand: 'feishu', botId: 'ou_bot',
|
accounts: { repository: 'acme/widgets', agent: 'agent-bot', feishu: { appId: 'cli_bot', brand: 'feishu', botId: 'ou_bot',
|
||||||
name: 'Widgets Bot' } } },
|
name: 'Widgets Bot' } } },
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ it('leads the overview with the pending Tickets, then problems, the last day and
|
|||||||
await mount()
|
await mount()
|
||||||
expect(tab(zh['tab.overview']).getAttribute('aria-selected')).toBe('true')
|
expect(tab(zh['tab.overview']).getAttribute('aria-selected')).toBe('true')
|
||||||
expect(all('[role="tab"]').map(tab => tab.textContent)).toEqual(['概览', '来源', '触发', '评估', '执行', '设置'])
|
expect(all('[role="tab"]').map(tab => tab.textContent)).toEqual(['概览', '来源', '触发', '评估', '执行', '设置'])
|
||||||
expect(all('.dt-section-title').map(title => title.textContent)).toEqual(['待处理工单2', '系统问题2', '存活', '过去 24 小时详情 ›',
|
expect(all('.dt-section-title').map(title => title.textContent)).toEqual(['待处理工单2', '系统问题2', '存活', '工单目录',
|
||||||
'执行器详情 ›'])
|
'过去 24 小时详情 ›', '执行器详情 ›'])
|
||||||
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual(['待回答', '待处理失败'])
|
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual(['待回答', '待处理失败'])
|
||||||
expect(all('.dt-cards .dt-att-what').map(item => item.textContent)).toEqual(['飞书 来源异常', '评估未完成:这个文案好奇怪'])
|
expect(all('.dt-cards .dt-att-what').map(item => item.textContent)).toEqual(['飞书 来源异常', '评估未完成:这个文案好奇怪'])
|
||||||
// Actions sit below the title and Threads, apart from the stage, each with its icon.
|
// Actions sit below the title and Threads, apart from the stage, each with its icon.
|
||||||
@@ -22,9 +22,11 @@ it('leads the overview with the pending Tickets, then problems, the last day and
|
|||||||
expect(text()).toContain('需要确认按钮颜色')
|
expect(text()).toContain('需要确认按钮颜色')
|
||||||
// Durations are formatted by the interface language, so the readout is asserted by label, and only the polling word verbatim.
|
// Durations are formatted by the interface language, so the readout is asserted by label, and only the polling word verbatim.
|
||||||
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([zh.livenessUptime, zh.livenessLastPoll, zh.livenessPolling,
|
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([zh.livenessUptime, zh.livenessLastPoll, zh.livenessPolling,
|
||||||
|
zh.workspaceHeld, zh.workspaceCandidates, zh.workspaceDirCount, zh.reclaimAfterDays,
|
||||||
zh.flowReceivedLabel, zh.flowFilteredLabel, zh.flowJudgedLabel, zh.flowAcceptedLabel, zh.executingLabel, zh.queuedLabel,
|
zh.flowReceivedLabel, zh.flowFilteredLabel, zh.flowJudgedLabel, zh.flowAcceptedLabel, zh.executingLabel, zh.queuedLabel,
|
||||||
zh.idleLabel])
|
zh.idleLabel])
|
||||||
expect(all('.dt-stat-value').map(value => value.textContent).slice(3)).toEqual(['12', '2', '5', '1', '2 / 3', '0', '1'])
|
expect(all('.dt-stat-value').map(value => value.textContent).slice(7)).toEqual(['12', '2', '5', '1', '2 / 3', '0', '1'])
|
||||||
|
expect(all('.dt-stat-value').map(value => value.textContent)[3]).toBe('58.0 MB')
|
||||||
expect(all('.dt-stat-value').map(value => value.textContent).slice(0, 3)).toEqual([expect.stringContaining('小时'), expect
|
expect(all('.dt-stat-value').map(value => value.textContent).slice(0, 3)).toEqual([expect.stringContaining('小时'), expect
|
||||||
.stringContaining('秒'), zh.livenessPolling])
|
.stringContaining('秒'), zh.livenessPolling])
|
||||||
expect(all('.dt-chip').map(chip => chip.textContent)[0]).toBe('GitHub#42CI 失败1')
|
expect(all('.dt-chip').map(chip => chip.textContent)[0]).toBe('GitHub#42CI 失败1')
|
||||||
@@ -134,9 +136,10 @@ it('words a refused retry in the interface language by its code', async () => {
|
|||||||
it('shows the overview in English', async () => {
|
it('shows the overview in English', async () => {
|
||||||
await mount(en)
|
await mount(en)
|
||||||
expect(all('.dt-section-title').map(title => title.textContent)).toEqual([`${en.pendingTickets}2`, `${en.problems}2`,
|
expect(all('.dt-section-title').map(title => title.textContent)).toEqual([`${en.pendingTickets}2`, `${en.problems}2`,
|
||||||
en.livenessTitle, `${en.lastDay}${en.details}`, `${en['term.executor']}${en.details}`])
|
en.livenessTitle, en.workspaceTitle, `${en.lastDay}${en.details}`, `${en['term.executor']}${en.details}`])
|
||||||
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual([en['stage.answer'], en['stage.failed']])
|
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual([en['stage.answer'], en['stage.failed']])
|
||||||
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([en.livenessUptime, en.livenessLastPoll, en.livenessPolling,
|
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([en.livenessUptime, en.livenessLastPoll, en.livenessPolling,
|
||||||
|
en.workspaceHeld, en.workspaceCandidates, en.workspaceDirCount, en.reclaimAfterDays,
|
||||||
en.flowReceivedLabel, en.flowFilteredLabel, en.flowJudgedLabel, en.flowAcceptedLabel, en.executingLabel, en.queuedLabel, en.idleLabel])
|
en.flowReceivedLabel, en.flowFilteredLabel, en.flowJudgedLabel, en.flowAcceptedLabel, en.executingLabel, en.queuedLabel, en.idleLabel])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,3 +177,11 @@ it('says when a Source stopped polling, with how long its last round was and the
|
|||||||
expect(line).toBe(zh.problemStalled.replace('{source}', 'GitHub').replace('{age}', duration(26 * 60_000, 'zh')))
|
expect(line).toBe(zh.problemStalled.replace('{source}', 'GitHub').replace('{age}', duration(26 * 60_000, 'zh')))
|
||||||
expect(text()).toContain(zh.problemStalledHint)
|
expect(text()).toContain(zh.problemStalledHint)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reads out what the ended Tickets still hold, and that nothing is ever removed when the retention is zero', async () => {
|
||||||
|
server.workspace = { root: '/srv/work', afterDays: 0, directories: 3, candidates: 0, bytes: 0, lastScanAt: new Date().toISOString(),
|
||||||
|
unreadable: 0 }
|
||||||
|
await mount()
|
||||||
|
expect(text()).toContain(zh.workspaceKeepForever)
|
||||||
|
expect(all('.dt-stat-value').map(value => value.textContent)).toContain('0')
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ it('saves the Eval block on the Policy settings and the Executor with its execut
|
|||||||
expect([...document.querySelectorAll('#dt-worker-model-efforts option')].map(option => option.getAttribute('value'))).toEqual(['high'])
|
expect([...document.querySelectorAll('#dt-worker-model-efforts option')].map(option => option.getAttribute('value'))).toEqual(['high'])
|
||||||
await click(zh.save)
|
await click(zh.save)
|
||||||
expect(server.settings.executor).toEqual({ maxWorkers: 5, directory: '/srv/work',
|
expect(server.settings.executor).toEqual({ maxWorkers: 5, directory: '/srv/work',
|
||||||
prepare: 'git worktree add "$DSH_SUPERAGENT_DIR"', blockedCommands: ['gh-personal', 'gh.other'], model: { provider: 'worker-provider',
|
prepare: 'git worktree add "$DSH_SUPERAGENT_DIR"', blockedCommands: ['gh-personal', 'gh.other'], reclaimAfterDays: 14,
|
||||||
model: 'worker-model', reasoningEffort: 'high' } })
|
model: { provider: 'worker-provider', model: 'worker-model', reasoningEffort: 'high' } })
|
||||||
expect(server.requests.filter(request => request.method === 'PUT').map(request => request.path)).toEqual(['settings/eval',
|
expect(server.requests.filter(request => request.method === 'PUT').map(request => request.path)).toEqual(['settings/eval',
|
||||||
'settings/executor'])
|
'settings/executor'])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ describe('settings migration', () => {
|
|||||||
sources: { feishu: feishuSource({ chats: [{ chatId: CHAT, listen: 'mentions' }, { chatId: OBSERVED, listen: 'all' }], directMessages: false }), github: { enabled: true, config: { pollSeconds: 120 } } },
|
sources: { feishu: feishuSource({ chats: [{ chatId: CHAT, listen: 'mentions' }, { chatId: OBSERVED, listen: 'all' }], directMessages: false }), github: { enabled: true, config: { pollSeconds: 120 } } },
|
||||||
policy: { rules: [], sources: { feishu: { allowed: [{ id: MEMBER, name: 'A' }], admission: {} }, github: { allowed: [{ id: 'a' }], admission: {} } } },
|
policy: { rules: [], sources: { feishu: { allowed: [{ id: MEMBER, name: 'A' }], admission: {} }, github: { allowed: [{ id: 'a' }], admission: {} } } },
|
||||||
eval: { mode: 'auto', timeoutSeconds: 90, model: { provider: 'p', model: 'm' } },
|
eval: { mode: 'auto', timeoutSeconds: 90, model: { provider: 'p', model: 'm' } },
|
||||||
executor: { model: null, maxWorkers: 5, directory: '', prepare: '', blockedCommands: [] },
|
executor: { model: null, maxWorkers: 5, directory: '', prepare: '', blockedCommands: [], reclaimAfterDays: 14 },
|
||||||
})
|
})
|
||||||
expect(migrateSettings(migrated)).toEqual(migrated)
|
expect(migrateSettings(migrated)).toEqual(migrated)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Reclaiming the directories of Ended Tickets: what may go, and what the readout counts.
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import { reclaimable, within } from '../src/app/reclaim.ts'
|
||||||
|
import type { Ticket } from '../src/core/index.ts'
|
||||||
|
|
||||||
|
const DAY = 86_400_000
|
||||||
|
const root = '/srv/dsh-superagent/work'
|
||||||
|
const now = Date.parse('2026-10-31T00:00:00.000Z')
|
||||||
|
const at = (daysAgo: number) => new Date(now - daysAgo * DAY).toISOString()
|
||||||
|
|
||||||
|
/** A Ticket as the reclaimer reads it: an id, its workspace, and when it ended. */
|
||||||
|
function ticket(id: string, name: string, ended: string | undefined, directory = `${root}/${name}`): Ticket {
|
||||||
|
return { id, title: id, decisionId: 'd', channels: [], undelivered: [], createdAt: at(30), updatedAt: at(30),
|
||||||
|
mandate: { strength: 'explicit', policyVersion: 'v', basis: { via: 'listed' }, actor: { source: 'feishu', id: 'ou_alice' }, scope: '' },
|
||||||
|
origin: { triggerId: 'e1', triggerIds: ['e1'] }, assignments: [], inputs: [],
|
||||||
|
...ended ? { ended: { at: ended, by: 'executor' as const, outcome: 'completed' as const } } : {},
|
||||||
|
execution: { name, directory, assignment: '' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('tells a path inside the execution root from one beside or above it', () => {
|
||||||
|
expect(within(root, `${root}/create-wiki-06e6e4ae`)).toBe(true)
|
||||||
|
expect(within(root, `${root}/a/b/c`)).toBe(true)
|
||||||
|
// A sibling whose name starts with the root's, a parent, and the root itself are not inside it.
|
||||||
|
expect(within(root, `${root}-other/ticket`)).toBe(false)
|
||||||
|
expect(within(root, '/srv/dsh-superagent')).toBe(false)
|
||||||
|
expect(within(root, root)).toBe(false)
|
||||||
|
expect(within(root, '/elsewhere/ticket')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reclaims an Ended Ticket\'s own directory once it outlives the retention, and no other', () => {
|
||||||
|
const tickets = [ticket('t1', 'create-wiki-06e6e4ae', at(20)), ticket('t2', 'create-hi-7aa4cedc44b4', at(2))]
|
||||||
|
expect(reclaimable(tickets, root, now, 14).map(item => item.ticketId)).toEqual(['t1'])
|
||||||
|
// A Ticket still open holds its directory however old it is.
|
||||||
|
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', undefined)], root, now, 14)).toEqual([])
|
||||||
|
// A retention of zero keeps everything, which is how an operator opts out entirely.
|
||||||
|
expect(reclaimable(tickets, root, now, 0)).toEqual([])
|
||||||
|
// The retention is a whole number of days, so a Ticket ended a moment ago waits.
|
||||||
|
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', at(13))], root, now, 14)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never reclaims a directory the Executor did not name under its root', () => {
|
||||||
|
// An operator's own directory outside the root: the Executor recorded it, but removing it would reach past the tree.
|
||||||
|
expect(reclaimable([ticket('t1', 'my-checkout', at(20), '/home/me/my-checkout')], root, now, 14)).toEqual([])
|
||||||
|
// A root that merely shares a prefix with the directory is not the parent of it.
|
||||||
|
expect(reclaimable([ticket('t1', 'ticket', at(20), `${root}-other/ticket`)], root, now, 14)).toEqual([])
|
||||||
|
// A directory the Executor recorded under a different name than the Ticket's own is not one it made.
|
||||||
|
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', at(20), `${root}/something-else`)], root, now, 14)).toEqual([])
|
||||||
|
// A Ticket that never got a directory, such as one never accepted, holds nothing.
|
||||||
|
const bare: Ticket = { ...ticket('t1', 'x', at(20)), execution: undefined }
|
||||||
|
expect(reclaimable([bare], root, now, 14)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a directory that an open Ticket uses, and lists each directory once', () => {
|
||||||
|
// A Ticket that ended is not removed while a newer open Ticket works in the same directory.
|
||||||
|
const shared = `${root}/create-wiki-06e6e4ae`
|
||||||
|
const tickets = [ticket('t1', 'create-wiki-06e6e4ae', at(20), shared), ticket('t2', 'create-wiki-06e6e4ae', undefined, shared)]
|
||||||
|
expect(reclaimable(tickets, root, now, 14)).toEqual([])
|
||||||
|
// Two ended Tickets naming the same directory reclaim it once, not twice.
|
||||||
|
const twice = [ticket('t1', 'create-wiki-06e6e4ae', at(20), shared), ticket('t2', 'create-wiki-06e6e4ae', at(25), shared)]
|
||||||
|
// Paths come back resolved, so the expectation is resolved too rather than spelled with the platform's separator.
|
||||||
|
expect(reclaimable(twice, root, now, 14)).toEqual([{ ticketId: 't1', directory: resolve(shared) }])
|
||||||
|
// Either order reclaims it once, named after whichever Ticket the list reaches first.
|
||||||
|
expect(reclaimable([...twice].reverse(), root, now, 14)).toEqual([{ ticketId: 't2', directory: resolve(shared) }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an Ended Ticket whose end time cannot be read', () => {
|
||||||
|
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', 'not a time')], root, now, 14)).toEqual([])
|
||||||
|
})
|
||||||
@@ -355,3 +355,11 @@ it('reports whether the service is alive and polling apart from whether any Trig
|
|||||||
expect(overview(host, {}, 'v1', load, undefined, startedAt).liveness).toMatchObject({ polling: false, lastPollAt: null, pollAgeMs: null })
|
expect(overview(host, {}, 'v1', load, undefined, startedAt).liveness).toMatchObject({ polling: false, lastPollAt: null, pollAgeMs: null })
|
||||||
expect(overview(host, {}, 'v1', load).liveness).toMatchObject({ startedAt: null, uptimeMs: null, polling: false })
|
expect(overview(host, {}, 'v1', load).liveness).toMatchObject({ startedAt: null, uptimeMs: null, polling: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports what the execution root holds for Ended Tickets apart from what any Trigger did', () => {
|
||||||
|
const host = views()
|
||||||
|
const workspace = { root: '/srv/work', afterDays: 14, directories: 5, candidates: 1, bytes: 1024, lastScanAt: at, unreadable: 0 }
|
||||||
|
expect(overview(host, {}, 'v1', load, undefined, undefined, workspace).workspace).toEqual(workspace)
|
||||||
|
// Before the first scan there is nothing to say, which the page renders as no readout rather than as zeroes.
|
||||||
|
expect(overview(host, {}, 'v1', load).workspace).toBeNull()
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user