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
2 changes: 1 addition & 1 deletion .modonome/snapshot/badge.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"color":"blue","label":"snapshot","message":"970 files, 131059 tok","schemaVersion":1}
{"color":"blue","label":"snapshot","message":"972 files, 131084 tok","schemaVersion":1}
2 changes: 1 addition & 1 deletion .modonome/snapshot/map.json

Large diffs are not rendered by default.

157 changes: 79 additions & 78 deletions .modonome/snapshot/map.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .modonome/snapshot/signature.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"commands":{"build":"","lint":"","test":"npm test"},"entrypoints":["bin/modonome.mjs"],"generated_at":"2026-07-07T19:07:46.806Z","generated_for":"modonome","governance":{"armed":false,"autonomy_enabled":false,"dry_run":true},"instructions":["AGENTS.md","CODEX.md","CONTRIBUTING.md","README.md"],"language_mix":{"(none)":11,"css":50,"diff":15,"html":9,"js":19,"json":133,"jsonl":7,"md":124,"mjs":337,"patch":37,"png":5,"py":4,"sh":1,"svg":1,"tf":4,"toml":2,"ts":61,"tsx":98,"txt":10,"webmanifest":1,"webp":1,"woff2":9,"xml":1,"yaml":9,"yml":21},"merkle_root":"sha256:5d3c50b55c031b400b0f2e57397bf56f3c406f684398974cff8ad06decaa520c","preamble":"Modonome snapshot. Read this before reading the repo. Tier 0 (signature.json) is the fingerprint: if merkle_root matches your last read, nothing changed. Tier 1 (map.json / map.md) lists modules, public API signatures, import edges, and attention ranking. Cite anchors (F: for files, S: for symbols); each resolves to a path and line so you can act without re-reading the whole repo.","protected_paths":[".github",".github/CODEOWNERS","package-lock.json"],"schema_version":1,"size":{"bytes":3573383,"files":970},"snapshot_version":23,"stack":{"name":"Node or TypeScript","pm":"npm"},"tier_hashes":{"map":"sha256:b05af15a3c2003503c0c606a7d509377e9ea167c22cc119dd2a655dfa54cc4e6","map_md":"sha256:3373b8be35f72845283ef1a554986729121b5879b141effe19fe7669a06f65db"}}
{"commands":{"build":"","lint":"","test":"npm test"},"entrypoints":["bin/modonome.mjs"],"generated_at":"2026-07-08T02:22:46.060Z","generated_for":"modonome","governance":{"armed":false,"autonomy_enabled":false,"dry_run":true},"instructions":["AGENTS.md","CODEX.md","CONTRIBUTING.md","README.md"],"language_mix":{"(none)":11,"css":50,"diff":15,"html":9,"js":19,"json":134,"jsonl":7,"md":124,"mjs":338,"patch":37,"png":5,"py":4,"sh":1,"svg":1,"tf":4,"toml":2,"ts":61,"tsx":98,"txt":10,"webmanifest":1,"webp":1,"woff2":9,"xml":1,"yaml":9,"yml":21},"merkle_root":"sha256:5c8b2b06867da2c07e8766db14a5a9901ac6dc781697cc4f2d203c8211cb42e3","preamble":"Modonome snapshot. Read this before reading the repo. Tier 0 (signature.json) is the fingerprint: if merkle_root matches your last read, nothing changed. Tier 1 (map.json / map.md) lists modules, public API signatures, import edges, and attention ranking. Cite anchors (F: for files, S: for symbols); each resolves to a path and line so you can act without re-reading the whole repo.","protected_paths":[".github",".github/CODEOWNERS","package-lock.json"],"schema_version":1,"size":{"bytes":3582195,"files":972},"snapshot_version":24,"stack":{"name":"Node or TypeScript","pm":"npm"},"tier_hashes":{"map":"sha256:cf9184050ba8b6a35bb1f3e9061bc3df7523ac20ad37268cac46732bfecbfd5d","map_md":"sha256:16a8db3f8ce2a1909ee0c5a1373ac796d806585125f0e695ab2df613799489a1"}}
25 changes: 25 additions & 0 deletions .modonome/work-items/WI-046-panel-schedule-trigger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"schema_version": 1,
"id": "WI-046-panel-schedule-trigger",
"state": "done",
"type": "develop-feature",
"attempts": 0,
"max_attempts": 3,
"touches_protected_path": false,
"lease_owner": "techseek4vr",
"allowed_edit_set": [
"apps/control-panel/src/state/types.ts",
"apps/control-panel/src/screens/SettingsScreen.tsx",
"tests/schedule-trigger-schema.test.mjs",
".modonome/work-items/WI-046-panel-schedule-trigger.json",
".modonome/snapshot/*",
".modonome/work-items/*"
],
"gates": [
"node scripts/check-control-panel-coherence.mjs",
"node scripts/check-control-panel-coverage.mjs",
"node --test tests/schedule-trigger-schema.test.mjs",
"npm run verify"
],
"queued_at": "2026-07-07"
}
119 changes: 119 additions & 0 deletions apps/control-panel/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export function SettingsScreen({ state, write }: { state: PanelState; write: Wri
const [newSkill, setNewSkill] = useState("");
const [newTool, setNewTool] = useState("");
const [priorityModelToAdd, setPriorityModelToAdd] = useState("");
const [newAfter, setNewAfter] = useState("");

const [modelDraft, setModelDraft] = useState<ModelDraft>(EMPTY_MODEL_DRAFT);
const [editingModelId, setEditingModelId] = useState<string | null>(null);
Expand Down Expand Up @@ -194,6 +195,71 @@ export function SettingsScreen({ state, write }: { state: PanelState; write: Wri
);
}

// Schedule and trigger editing (WI-046). A role's schedule.cron and trigger
// (a string shorthand or an object) are set here so an operator picks when and how
// each agent fires from the same tab, without hand-editing config.yaml.
type TriggerObject = Exclude<ModonomeConfig["roles"][string]["trigger"], string | undefined>;
type TriggerType = NonNullable<TriggerObject["type"]>;
const TRIGGER_TYPES: TriggerType[] = ["schedule", "manual", "webhook", "on-merge", "after-role"];

function triggerAsObject(t: ModonomeConfig["roles"][string]["trigger"]): TriggerObject {
if (!t) return {};
if (typeof t === "string") return { type: t as TriggerType };
return t;
}

function setRoleScheduleCron(role: string, cron: string) {
setConfig((c) => {
const trimmed = cron.trim();
const next = { ...c.roles[role] };
// An emptied field clears the schedule, so it is never saved as an invalid
// empty-string cron (the schema requires minLength 1).
if (trimmed) next.schedule = { ...next.schedule, cron: trimmed };
else delete next.schedule;
return { ...c, roles: { ...c.roles, [role]: next } };
});
}

function setRoleTriggerType(role: string, type: string) {
setConfig((c) => {
const next = { ...c.roles[role] };
if (!type) {
delete next.trigger;
} else {
const current = triggerAsObject(c.roles[role]?.trigger);
next.trigger = { ...current, type: type as TriggerType };
}
return { ...c, roles: { ...c.roles, [role]: next } };
});
}

function addRoleTriggerAfter(role: string, value: string) {
const v = value.trim();
if (!v) return;
setConfig((c) => {
const current = triggerAsObject(c.roles[role]?.trigger);
const after = current.after ?? [];
if (after.includes(v)) return c;
return {
...c,
roles: { ...c.roles, [role]: { ...c.roles[role], trigger: { ...current, after: [...after, v] } } },
};
});
}

function removeRoleTriggerAfter(role: string, value: string) {
setConfig((c) => {
const current = triggerAsObject(c.roles[role]?.trigger);
return {
...c,
roles: {
...c.roles,
[role]: { ...c.roles[role], trigger: { ...current, after: (current.after ?? []).filter((x) => x !== value) } },
},
};
});
}

function addAuthor() {
const author = newAuthor.trim();
if (!author || config.trusted_author_allowlist.includes(author)) return;
Expand Down Expand Up @@ -873,6 +939,59 @@ export function SettingsScreen({ state, write }: { state: PanelState; write: Wri
<Button type="submit" size="sm" disabled={!priorityModelToAdd}>Add</Button>
</form>
</div>

<div>
<p className="mdn-label">Schedule</p>
<Input
label="Cron"
placeholder="0 6 * * 1"
value={agent.schedule?.cron ?? ""}
onChange={(e) => setRoleScheduleCron(role, e.target.value)}
/>
</div>

<div>
<p className="mdn-label">Trigger</p>
<div style={{ maxWidth: 240 }}>
<Select
label="Trigger type"
hint="How this agent is fired: on a schedule, manually, by webhook, on merge, or after another role finishes."
options={[
{ value: "", label: "None" },
...TRIGGER_TYPES.map((t) => ({ value: t, label: t })),
]}
value={triggerAsObject(agent.trigger).type ?? ""}
onValueChange={(v) => setRoleTriggerType(role, v)}
/>
</div>
{triggerAsObject(agent.trigger).type === "after-role" ? (
<>
{(triggerAsObject(agent.trigger).after ?? []).length === 0 ? (
<p className="mdn-faint" style={{ margin: "4px 0" }}>None yet.</p>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, margin: "4px 0" }}>
{(triggerAsObject(agent.trigger).after ?? []).map((item) => (
<span key={item} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<StatusPill tone="neutral" size="sm">{item}</StatusPill>
<IconButton icon="x" label={`Remove ${item}`} size="sm" onClick={() => removeRoleTriggerAfter(role, item)} />
</span>
))}
</div>
)}
<form
style={{ display: "flex", gap: 8, alignItems: "flex-end" }}
onSubmit={(e) => {
e.preventDefault();
addRoleTriggerAfter(role, newAfter);
setNewAfter("");
}}
>
<Input label="Run after role" placeholder="researcher" value={newAfter} onChange={(e) => setNewAfter(e.target.value)} />
<Button type="submit" size="sm">Add</Button>
</form>
</>
) : null}
</div>
</div>
);
})()}
Expand Down
12 changes: 11 additions & 1 deletion apps/control-panel/src/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,17 @@ export interface ModonomeConfig {
tools?: string[];
provider?: string;
transport?: string;
trigger?: string;
/** Declarative cadence for a scheduled crew role. cron is a standard 5-field
* expression; timezone is an optional IANA name. */
schedule?: { cron: string; timezone?: string };
/** How this role is fired. A bare string is shorthand for { type: <string> }. */
trigger?:
| string
| {
type?: "schedule" | "manual" | "webhook" | "on-merge" | "after-role";
after?: string[];
cron?: string;
};
execution_target?: string;
}
>;
Expand Down
43 changes: 43 additions & 0 deletions tests/schedule-trigger-schema.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Schema-level coverage for the schedule and trigger role fields (WI-032 added them,
// WI-046 exposes them in the control panel). These check the role sub-schema accepts the
// valid shapes and rejects the invalid ones, so the panel and the loop agree on what a
// role may declare. The validator (scripts/lib/jsonschema.mjs) does not recurse into an
// additionalProperties schema, so these assert against the role sub-schema directly
// (schemas/config.schema.json -> properties.roles.additionalProperties) rather than a
// full top-level config.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { validate } from "../scripts/lib/jsonschema.mjs";

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
const schema = JSON.parse(readFileSync(join(root, "schemas", "config.schema.json"), "utf8"));
const roleSchema = schema.properties.roles.additionalProperties;

test("a role with schedule.cron validates", () => {
const errors = validate(roleSchema, { schedule: { cron: "0 6 * * 1" } });
assert.deepEqual(errors, []);
});

test("trigger as a bare string shorthand validates", () => {
const errors = validate(roleSchema, { trigger: "manual" });
assert.deepEqual(errors, []);
});

test("trigger as an after-role object validates", () => {
const errors = validate(roleSchema, { trigger: { type: "after-role", after: ["researcher"] } });
assert.deepEqual(errors, []);
});

test("a bad trigger.type enum is rejected", () => {
const errors = validate(roleSchema, { trigger: { type: "whenever" } });
assert.ok(errors.length > 0, "expected a validation error for an out-of-enum trigger.type");
});

test("an unknown sub-key under an object trigger is rejected", () => {
const errors = validate(roleSchema, { trigger: { type: "manual", cadence: "weekly" } });
assert.ok(errors.length > 0, "expected a validation error for an unknown trigger sub-key");
});
Loading