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
1 change: 1 addition & 0 deletions client/src/components/cos/tabs/schedule/AppTaskCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfi
apps={apps}
onTrigger={onTrigger}
installWide={config.installWide}
programmatic={config.programmatic}
disabledReason={improvementDisabled ? IMPROVEMENT_DISABLED_TITLE : (pins.saving ? SAVING_TITLE : '')}
/>
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
apps={apps}
onTrigger={onTrigger}
installWide={config.installWide}
programmatic={config.programmatic}
// `updating` covers an in-flight pin write here, same race the card gates on.
disabledReason={improvementDisabled ? IMPROVEMENT_DISABLED_TITLE : (updating ? SAVING_TITLE : '')}
/>
Expand Down
16 changes: 16 additions & 0 deletions client/src/components/cos/tabs/schedule/PromptEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ export default function PromptEditor({ config, promptValue, setPromptValue, edit
}, [stages?.length, activeTab]);

if (!hasPipeline) {
// A PROGRAMMATIC task has no prompt at all — PortOS performs the work
// itself. Distinct from 'runtime-generated' (a hook renders a real prompt
// for a real agent), so it must not offer to show one.
if (config.promptMode === 'programmatic') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">Task Prompt</span>
<span className="text-[10px] px-1.5 py-0.5 bg-port-accent/10 text-port-accent rounded">No prompt — PortOS runs this itself</span>
</div>
<div className="bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-gray-400">
{config.promptDescription || 'This task is performed by PortOS directly, with no agent and no prompt.'}
</div>
</div>
);
}
if (config.promptMode === 'runtime-generated') {
return (
<div className="space-y-2">
Expand Down
27 changes: 27 additions & 0 deletions client/src/components/cos/tabs/schedule/PromptEditor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,30 @@ describe('PromptEditor', () => {
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
});

describe('PromptEditor — programmatic tasks', () => {
it('offers no prompt at all for work PortOS performs itself', () => {
// Distinct from 'runtime-generated': there is no agent and no prompt, so
// offering to show or edit one would describe execution that never happens.
render(
<PromptEditor
config={{
promptMode: 'programmatic',
promptDescription: 'PortOS enqueues the renders itself.'
}}
promptValue=""
setPromptValue={() => {}}
editingPrompt={false}
setEditingPrompt={() => {}}
handleSavePrompt={() => {}}
updating={false}
activeApps={[]}
/>
);

expect(screen.getByText('No prompt — PortOS runs this itself')).toBeInTheDocument();
expect(screen.getByText('PortOS enqueues the renders itself.')).toBeInTheDocument();
expect(screen.queryByText('Generated at run time')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
});
7 changes: 5 additions & 2 deletions client/src/components/cos/tabs/schedule/RunTaskButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const MENU_WIDTH = 256; // w-64
// A named boolean per reason would mean every new reason edits this component,
// and — as the pin-saving gate showed — reaching only whichever call site the
// author had in mind.
export default function RunTaskButton({ taskType, apps, onTrigger, installWide = false, disabledReason = '' }) {
export default function RunTaskButton({ taskType, apps, onTrigger, installWide = false, programmatic = false, disabledReason = '' }) {
const [open, setOpen] = useState(false);
const [triggering, setTriggering] = useState(false);
const [lastRequest, setLastRequest] = useState('');
Expand Down Expand Up @@ -101,7 +101,10 @@ export default function RunTaskButton({ taskType, apps, onTrigger, installWide =
// run is the app-less one. Without this the picker would be the only way to
// start it on any install that has apps, and every click would send an appId —
// silently reducing an install-wide sweep to a single repo.
if (activeApps.length === 0 || installWide) {
// A PROGRAMMATIC task (universe bible descriptions/images) acts on PortOS's
// own records, never a managed app's checkout — the server rejects a request
// that names one — so it gets the plain button, not the app picker.
if (activeApps.length === 0 || installWide || programmatic) {
return (
<span className="inline-flex min-w-0 flex-col items-start">
<span
Expand Down
14 changes: 14 additions & 0 deletions client/src/components/cos/tabs/schedule/RunTaskButton.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ describe('RunTaskButton', () => {
expect(screen.queryByText('Example App')).toBeNull();
});

it('runs a programmatic task with NO app, and without the all-apps label', async () => {
// A programmatic handler acts on PortOS's own records; the server rejects
// a request that names a managed app, so the picker would produce a dead
// button on any install that has apps. It is not an install-wide SWEEP
// either, so it must not claim to run on all of them.
const user = userEvent.setup();
const onTrigger = vi.fn();
render(<RunTaskButton taskType="universe-bible-images" apps={APPS} onTrigger={onTrigger} programmatic />);
expect(screen.queryByRole('button', { name: /Run on All Apps/i })).toBeNull();
await user.click(screen.getByRole('button', { name: /Run Now/i }));
expect(onTrigger).toHaveBeenCalledWith('universe-bible-images');
expect(onTrigger.mock.calls[0]).toHaveLength(1);
});

it('lists only active apps and runs the task on the picked one', async () => {
const user = userEvent.setup();
const onTrigger = vi.fn();
Expand Down
17 changes: 17 additions & 0 deletions client/src/hooks/useOnDemandTaskToast.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,26 @@ export function useOnDemandTaskToast() {
});
};

// A PROGRAMMATIC scheduled handler (universe bible descriptions/images)
// finishes its work inside the server — no agent task appears in the CoS
// queue for the user to watch — so its outcome is the only feedback the Run
// Now button can produce. `dispatched: false` is a decline, not a failure
// (nothing to do, or a setting that needs picking), so it stays calm and
// always names the handler's own reason rather than a generic gloss.
const handleHandled = (data) => {
const task = data?.taskType || 'task';
if (data?.dispatched) {
toast(data.summary || `${task}: done.`, { duration: 7000, icon: '✅' });
return;
}
toast(`${task}: ${data?.reason || 'nothing to do right now'}.`, { duration: 7000, icon: '💤' });
};

socket.on('cos:schedule:on-demand-empty', handleEmpty);
socket.on('cos:schedule:on-demand-handled', handleHandled);
return () => {
socket.off('cos:schedule:on-demand-empty', handleEmpty);
socket.off('cos:schedule:on-demand-handled', handleHandled);
// Don't unsubscribe from cos — other components share the room.
};
}, []);
Expand Down
31 changes: 31 additions & 0 deletions client/src/hooks/useOnDemandTaskToast.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,34 @@ describe('useOnDemandTaskToast — parked outcome', () => {
expect(msg).toMatch(/0 of 10 open/);
});
});

describe('useOnDemandTaskToast — programmatic handler results', () => {
beforeEach(() => { handlers.clear(); toastSpy.mockClear(); });
afterEach(cleanup);

const fireHandled = (payload) => handlers.get('cos:schedule:on-demand-handled')?.(payload);

it('reports what the handler actually did — no agent task appears to watch', () => {
renderHook(() => useOnDemandTaskToast());
fireHandled({
taskType: 'universe-bible-describe', dispatched: true,
summary: 'Described 3 of 3 bible entries (7 fields) in "Example Universe"',
});
const [msg, opts] = toastSpy.mock.calls[0];
expect(msg).toMatch(/Described 3 of 3 bible entries/);
expect(opts.icon).toBe('✅');
});

it('names the handler\'s own reason when it declines', () => {
// A decline is not a failure — nothing to do, or a setting that needs
// picking — so it stays calm but must not be a silent no-op.
renderHook(() => useOnDemandTaskToast());
fireHandled({
taskType: 'universe-bible-images', dispatched: false,
summary: null, reason: 'no bible entries are missing images',
});
const [msg, opts] = toastSpy.mock.calls[0];
expect(msg).toMatch(/no bible entries are missing images/);
expect(opts.icon).toBe('💤');
});
});
35 changes: 29 additions & 6 deletions docs/QUOTA-BURN.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ its own type, optional model/provider pin, and type-specific params.
| `universe-bible-describe` | **Programmatic** — no agent. Sends one headless expand prompt per under-described bible entry, pinned to the burning family's own CLI/TUI provider. |
| `universe-bible-images` | **Programmatic** — no agent. Enqueues renders for universe bible entries whose `imageRefs[]` is empty. Render backend defaults to the burning family's own image mode, so a codex burn spends codex's image quota. |

Both programmatic types are also ordinary **on-demand scheduled tasks** — they
appear in CoS → Schedule with their own settings and a Run Now button, and a burn
step and a manual run go through the same handler
(`server/services/scheduledHandlers/`). They are never clock-due: a fresh install
spends nothing on them until someone presses Run or adds one to a burn plan.
Passing the burning `family` is what pins the provider / render backend to the
subscription being drained; a manual run has no family and resolves the way any
other scheduled task does.

### Describe before you render

`universe-bible-describe` is the step that belongs **before** `universe-bible-images`
Expand Down Expand Up @@ -151,7 +160,7 @@ Locked entries are never picked, and every attempted entry is stamped into the
shared in-flight ledger for its 6-hour TTL. Why picks are ranked by blank
*fraction* rather than raw gap count, and why the stamp covers entries the model
declined to fill, are argued at the code site
(`server/services/quotaBurnJobs/universeBibleDescribe.js`).
(`server/services/scheduledHandlers/universeBibleDescribe.js`).

The image job's opt-in `requireDescribed` is the other half of the pairing: with
it on, canon entries with no `core` description are held out of the render
Expand Down Expand Up @@ -216,10 +225,23 @@ accord instead of looping.
dispatches nothing — the next cycle still faces every gate.

Adding a job type is three edits: a `QUOTA_BURN_JOB_TYPE` entry + catalog row in
`server/lib/quotaBurnConfig.js`, a module in `server/services/quotaBurnJobs/`, and
one line in that directory's `JOB_MODULES`. The config page builds its form from
the catalog, so no client change is needed unless the job introduces a param kind
the form doesn't render yet.
`server/lib/quotaBurnConfig.js`, a module, and one line in
`server/services/quotaBurnJobs/index.js`'s `JOB_MODULES`. The config page builds
its form from the catalog, so no client change is needed unless the job
introduces a param kind the form doesn't render yet.

**A PROGRAMMATIC job's module lives in `server/services/scheduledHandlers/`, not
in `quotaBurnJobs/`.** The two universe-bible actions are ordinary **on-demand
scheduled tasks** (CoS → Schedule → Run Now) that Quota Burn also dispatches;
`JOB_MODULES` points at the same handler, so there is one implementation rather
than a quota-only copy that drifts. A new burn action that PortOS performs itself
belongs there too — register it in `SCHEDULED_HANDLER_MODULES`, add its task type
to `PROGRAMMATIC_SCHEDULED_TASK_TYPES` + `DEFAULT_TASK_INTERVALS` in
`server/services/taskScheduleRegistry.js` (enabled, `ON_DEMAND`, no interval), and
allow-list its params in `sanitizeTaskMetadata`. The scheduled task's saved
`taskMetadata` is the params bag; a burn step passes its own `params` plus the
burning `family`, which is what pins the provider/render backend to that
subscription. Listing or probing must spend nothing.

Each job module exports `countPending` (side-effect free — the page calls it on
every load) and `run` (the only thing that may spend quota). `countPending` may
Expand Down Expand Up @@ -360,7 +382,8 @@ folds those overrides into the single plan (each app's family prompt becomes an
| `server/services/quotaBurn.js` | `evaluateFamily` — the one gate ladder both selection and the page's skip reasons read — plus the dispatch ledger |
| `server/services/quotaBurnCompletions.js` | The `run once` completion ledger and its re-arm |
| `server/services/quotaBurnDenials.js` | The observed-refusal ledger and its `agent:completed` subscriber |
| `server/services/quotaBurnJobs/` | The job registry and its modules |
| `server/services/quotaBurnJobs/` | The burn job registry and the `agent-prompt` executor |
| `server/services/scheduledHandlers/` | The programmatic handlers (universe bible descriptions/images) — shared by Scheduled Tasks and Quota Burn |
| `server/services/quotaBurnRunner.js` | The loop, the cycle, and the status feed |
| `server/routes/quotaBurn.js` | `/api/quota-burn` |
| `client/src/pages/QuotaBurn.jsx` | The config page |
59 changes: 58 additions & 1 deletion server/lib/cosValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isPlainObject } from './objects.js';
import { EFFORT_LEVELS } from './providerModels.js';
import { isValidSlashdoCommand } from './slashdoInvocation.js';
import { PR_COMPLETION_VALUES } from './prDisposition.js';
import { QUEUEABLE_IMAGE_MODES } from './generationModes.js';
import { PUBLIC_REVIEW_EXECUTION_PROFILES } from './agentExecutionProfiles.js';
import { ORCHESTRATION_MODES, ORCHESTRATION_ROLES } from './orchestrationProfile.js';
import { AGENT_RUN_EVENT_KINDS, RUN_EVENT_READ_LIMITS } from './agentRunEvents.js';
Expand Down Expand Up @@ -781,9 +782,37 @@ const ALLOWED_TASK_METADATA_KEYS = [
// Dispatch gate: when true, the generated system task is always awaiting-
// approve — including an explicit Run Now. Absent/false keeps the default
// (Run Now consents; unattended runs follow confidence/safety-kind).
'requireApproval'
'requireApproval',
// `universe-bible-images`: hold canon entries that have no description yet
// out of the render batch, so image quota isn't spent on a name with nothing
// behind it. See services/scheduledHandlers/universeBibleImages.js.
'requireDescribed'
];

// The params bag for the PROGRAMMATIC scheduled handlers
// (services/scheduledHandlers/) is stored as taskMetadata, so its non-boolean
// keys need the same constrained treatment as `verifyMode` / `branchesPerAgent`
// below — a hand-edited schedule must not smuggle an arbitrary string into a
// value the handler feeds to a store lookup or a render backend.
//
// `scope` accepts the UNION of the two handlers' option lists: task metadata is
// keyed by task type but sanitized by one type-agnostic function, and each
// handler already normalizes a scope it doesn't recognize back to 'all'
// (`SCOPE_KINDS[scope] || SCOPE_KINDS.all`, `wantsScope`). Widening here can
// therefore only accept a value the wrong handler ignores, never run the wrong
// work — which is why the enum stays explicit rather than becoming a free string.
export const BIBLE_HANDLER_SCOPES = ['all', 'characters', 'places', 'objects', 'variations', 'canon', 'sheets'];
export const BIBLE_HANDLER_DEPTHS = ['core', 'full'];
// Mirrors QUOTA_BURN_BOUNDS.maxEntries in lib/quotaBurnConfig.js — the same
// range the burn job form advertises. Both doors bound the same batch.
export const BIBLE_HANDLER_MAX_ENTRIES = { min: 1, max: 50 };
// `universeId` is a universe collection-store id, or the literal 'all'. Held to
// the store's OWN id alphabet (createCollectionStore's `idPattern`) so a
// hand-edited schedule can't put a path segment where a record id belongs.
const UNIVERSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const isBibleUniverseId = (value) =>
value === 'all' || (typeof value === 'string' && UNIVERSE_ID_PATTERN.test(value));

// pr-watcher author-gate values. 'self' = PRs opened by the gh-authenticated
// user (the PortOS operator / their automation); 'others' = everyone else;
// 'any' = no gate. Kept here so both the sanitizer and the prWatcher service
Expand Down Expand Up @@ -1052,6 +1081,34 @@ export function sanitizeTaskMetadata(raw) {
clean.branchesPerAgent = raw.branchesPerAgent;
hasKeys = true;
}
// The programmatic bible handlers' params bag (BIBLE_HANDLER_* above). Each
// key is dropped when it doesn't match, so a bad value falls back to the
// handler's own default instead of reaching a store lookup or a render
// backend. `mode` accepts only a QUEUEABLE image backend: `external` renders
// through a remote SD-API that batch rendering rejects downstream anyway, and
// storing it would advertise a setting that always fails at dispatch.
if (isBibleUniverseId(raw.universeId)) {
clean.universeId = raw.universeId;
hasKeys = true;
}
if (BIBLE_HANDLER_SCOPES.includes(raw.scope)) {
clean.scope = raw.scope;
hasKeys = true;
}
if (BIBLE_HANDLER_DEPTHS.includes(raw.depth)) {
clean.depth = raw.depth;
hasKeys = true;
}
if (Number.isInteger(raw.maxEntries)
&& raw.maxEntries >= BIBLE_HANDLER_MAX_ENTRIES.min
&& raw.maxEntries <= BIBLE_HANDLER_MAX_ENTRIES.max) {
clean.maxEntries = raw.maxEntries;
hasKeys = true;
}
if (QUEUEABLE_IMAGE_MODES.includes(raw.mode)) {
clean.mode = raw.mode;
hasKeys = true;
}
// Pipeline configuration is the one nested task-metadata shape. Keep only
// known stage fields and fail the whole update when a known field is malformed
// so a bad custom pipeline cannot silently lose its safety posture.
Expand Down
10 changes: 10 additions & 0 deletions server/services/cos.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
generateManagedAppImprovementTaskForType,
recordDeferredPerpetualDispatch,
applyOnDemandConsent,
drainProgrammaticOnDemandRequests,
emitOnDemandEmpty,
blockIfExceedsMaxSpawns,
selectDryRunAutoApproved,
Expand Down Expand Up @@ -996,10 +997,19 @@ async function spawnDequeuePriority0OnDemand(ctx) {
// (avoids a second load).
ctx.taskSchedule = taskSchedule;

// Programmatic handlers first, and outside the slot-bounded loop below: they
// spawn nothing, so a full spawn budget must not hold a user's Run Now.
const handledProgrammatically = await drainProgrammaticOnDemandRequests({
taskScheduleMod, requests: onDemandRequests, schedule: taskSchedule, state
});

// Track apps already marked review-started this cycle so multiple on-demand
// requests for the same app don't each rewrite its activity record.
const reviewStartedApps = new Set();
for (const request of onDemandRequests) {
// Already handled above (and its request cleared) — `onDemandRequests` is a
// snapshot taken before that drain.
if (handledProgrammatically.has(request.id)) continue;
if (capacity.spawned >= capacity.availableSlots) break;

if (!isImprovementEnabled(state)) {
Expand Down
Loading