@volter/supercode-orchestrator 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The applier's door into one Hermes home: Hermes's own functions, in the order
|
|
2
|
+
Hermes's own callers run them (docs/plans/content-spec-status.md §7).
|
|
3
|
+
|
|
4
|
+
Run under Hermes's interpreter with HERMES_HOME set to the home being applied.
|
|
5
|
+
One request on stdin, one JSON answer on stdout (the line marked MARK): {"ok": true, "result": ...} or
|
|
6
|
+
{"ok": false, "error": "..."}. Nothing here writes a Hermes file itself.
|
|
7
|
+
|
|
8
|
+
list cron.jobs.load_jobs()
|
|
9
|
+
create {kwargs} the prompt scan the agent's tool and the API
|
|
10
|
+
server run, then create_job_with_scheduler_registration,
|
|
11
|
+
then _notify_provider_jobs_changed
|
|
12
|
+
update {id, updates} update_job, then the notify
|
|
13
|
+
pause {id, reason} pause_job, then the notify
|
|
14
|
+
resume {id} resume_job, then the notify
|
|
15
|
+
remove {id} remove_job, then the notify
|
|
16
|
+
capture {id} a job's durable data before it is deleted: its notepad
|
|
17
|
+
(cron.notepad.list_notes) and its run outputs
|
|
18
|
+
config_read {keys} load_config(): the effective value of each dotted key, with the
|
|
19
|
+
file's own ${VAR} template where the value is its expansion
|
|
20
|
+
(_preserve_env_ref_templates against read_raw_config)
|
|
21
|
+
config_write {values} save_config(<those keys only>, merge_existing=True)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _notify():
|
|
29
|
+
try:
|
|
30
|
+
from cron.scheduler import _notify_provider_jobs_changed
|
|
31
|
+
_notify_provider_jobs_changed()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass # best-effort, as every Hermes caller treats it
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _dig(tree, dotted):
|
|
37
|
+
node = tree
|
|
38
|
+
for part in dotted.split("."):
|
|
39
|
+
if not isinstance(node, dict) or part not in node:
|
|
40
|
+
return None
|
|
41
|
+
node = node[part]
|
|
42
|
+
return node
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _nest(values):
|
|
46
|
+
tree = {}
|
|
47
|
+
for dotted, value in values.items():
|
|
48
|
+
node = tree
|
|
49
|
+
parts = dotted.split(".")
|
|
50
|
+
for part in parts[:-1]:
|
|
51
|
+
node = node.setdefault(part, {})
|
|
52
|
+
node[parts[-1]] = value
|
|
53
|
+
return tree
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
request = json.loads(sys.stdin.read() or "{}")
|
|
58
|
+
op = request.get("op")
|
|
59
|
+
args = request.get("args") or {}
|
|
60
|
+
if op == "list":
|
|
61
|
+
from cron.jobs import load_jobs
|
|
62
|
+
return load_jobs()
|
|
63
|
+
if op == "create":
|
|
64
|
+
kwargs = dict(args.get("kwargs") or {})
|
|
65
|
+
prompt = kwargs.get("prompt")
|
|
66
|
+
if prompt:
|
|
67
|
+
from tools.cronjob_tools import _scan_cron_prompt
|
|
68
|
+
scan_error = _scan_cron_prompt(prompt)
|
|
69
|
+
if scan_error:
|
|
70
|
+
raise ValueError(scan_error)
|
|
71
|
+
from cron.scheduler import create_job_with_scheduler_registration
|
|
72
|
+
job = create_job_with_scheduler_registration(**kwargs)
|
|
73
|
+
_notify()
|
|
74
|
+
return job
|
|
75
|
+
if op in ("update", "pause", "resume", "remove"):
|
|
76
|
+
from cron import jobs as store
|
|
77
|
+
job_id = args["id"]
|
|
78
|
+
if op == "update":
|
|
79
|
+
prompt = (args.get("updates") or {}).get("prompt")
|
|
80
|
+
if prompt:
|
|
81
|
+
from tools.cronjob_tools import _scan_cron_prompt
|
|
82
|
+
scan_error = _scan_cron_prompt(prompt)
|
|
83
|
+
if scan_error:
|
|
84
|
+
raise ValueError(scan_error)
|
|
85
|
+
result = store.update_job(job_id, args.get("updates") or {})
|
|
86
|
+
elif op == "pause":
|
|
87
|
+
result = store.pause_job(job_id, args.get("reason"))
|
|
88
|
+
elif op == "resume":
|
|
89
|
+
result = store.resume_job(job_id)
|
|
90
|
+
else:
|
|
91
|
+
result = store.remove_job(job_id)
|
|
92
|
+
if result is None or result is False:
|
|
93
|
+
raise LookupError(f"Hermes has no job {job_id}")
|
|
94
|
+
_notify()
|
|
95
|
+
return result
|
|
96
|
+
if op == "capture":
|
|
97
|
+
from cron.notepad import list_notes
|
|
98
|
+
from cron.jobs import _job_output_dir
|
|
99
|
+
job_id = args["id"]
|
|
100
|
+
outputs = {}
|
|
101
|
+
try:
|
|
102
|
+
folder = _job_output_dir(job_id)
|
|
103
|
+
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
|
104
|
+
outputs[path.name] = path.read_text(encoding="utf-8", errors="replace")
|
|
105
|
+
except ValueError:
|
|
106
|
+
pass
|
|
107
|
+
return {"notes": list_notes(job_id), "outputs": outputs}
|
|
108
|
+
if op == "config_read":
|
|
109
|
+
from hermes_cli.config import _preserve_env_ref_templates, load_config, read_raw_config
|
|
110
|
+
effective = load_config() or {}
|
|
111
|
+
raw = read_raw_config() or {}
|
|
112
|
+
# load_config expands ${VAR}; a value declared as a template reads back as the template it is on disk
|
|
113
|
+
return {key: _preserve_env_ref_templates(_dig(effective, key), _dig(raw, key)) for key in args.get("keys") or []}
|
|
114
|
+
if op == "config_write":
|
|
115
|
+
from hermes_cli.config import save_config
|
|
116
|
+
save_config(_nest(args.get("values") or {}), merge_existing=True)
|
|
117
|
+
return True
|
|
118
|
+
raise ValueError(f"unknown op {op!r}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
MARK = "@@supercode-apply@@" # Hermes may print while importing; the answer is the marked line
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
try:
|
|
126
|
+
answer = {"ok": True, "result": main()}
|
|
127
|
+
except Exception as exc: # the applier reports it per record
|
|
128
|
+
answer = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
129
|
+
print(MARK + json.dumps(answer, default=str), flush=True)
|
package/apply/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The applier's entry point: the same declarations as the root's "agent-setup applier" (index.d.ts), under the names
|
|
2
|
+
// this module exports them by (index.mjs re-exports them renamed). A consumer that loads only the applier imports this
|
|
3
|
+
// path and keeps its types.
|
|
4
|
+
export { planSetup as plan, applySetup as apply, adoptSetup as adopt, resolveSetup as resolve, provisionHome as provision, validateSetup as validate, resolveParameters, hermesConfig, SETUP_SCHEMA_VERSION as SCHEMA_VERSION } from '@volter/supercode-orchestrator';
|
package/apply/index.mjs
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// The agent-setup applier (docs/plans/content-spec-status.md §7): a package's declared jobs, its
|
|
2
|
+
// Inference and its harness settings reach one home through the gateway's own door (doors.mjs), against
|
|
3
|
+
// a base the applier keeps in its own state (state.mjs). Ownership is by harness id, never by name.
|
|
4
|
+
// Every record gets one row of §7's table:
|
|
5
|
+
//
|
|
6
|
+
// owned, spec equals base and live equals read-back stamp
|
|
7
|
+
// spec changed, live equals read-back act (only the fields that differ from live)
|
|
8
|
+
// live changed, spec unchanged conflict, reported to the manager
|
|
9
|
+
// both changed conflict, reported
|
|
10
|
+
// declared, no base, no unowned live record with the key create (home provisioned; else plan-only)
|
|
11
|
+
// declared, no base, an unowned live record has the key unowned, reported (adopt is an act)
|
|
12
|
+
// owned, live in error the harness's error, reported
|
|
13
|
+
// owned, live missing, last lifecycle completed forgotten (lifecycle), no report
|
|
14
|
+
// owned, live missing otherwise deleted, reported, never re-created
|
|
15
|
+
// owned, `removed` in the declaration forget | pause | delete (delete only through a
|
|
16
|
+
// reviewed plan, after its durable data is captured)
|
|
17
|
+
// owned, bare removal from the declaration still live, reported
|
|
18
|
+
// a record the target cannot express refused: that record only
|
|
19
|
+
//
|
|
20
|
+
// `act` and `create` need no judgment and run whenever apply runs; every report goes to the manager.
|
|
21
|
+
// Inference and harness settings are planned and applied before triggers. A plan is bound by its hash
|
|
22
|
+
// to the base, the spec and the live records it read; applying a stale plan is refused.
|
|
23
|
+
|
|
24
|
+
import { canonical, hash, lock, provision as provisionState, readIntents, readState, stateRoot, writeCapture, writeIntents, writeState } from './state.mjs';
|
|
25
|
+
import { MANAGED_JOB_FIELDS, namedModel } from './doors.mjs';
|
|
26
|
+
|
|
27
|
+
export const SCHEMA_VERSION = 1;
|
|
28
|
+
/** How long after an intent a live record may be taken as the applier's own create. */
|
|
29
|
+
const INTENT_WINDOW_MS = 10 * 60_000;
|
|
30
|
+
|
|
31
|
+
const managedOf = (row) => canonical(Object.fromEntries(MANAGED_JOB_FIELDS.map((f) => [f, row.managed[f] ?? null])));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parameters (plan §6): scalars the manifest names, resolved at render time
|
|
35
|
+
* from the values the runtime passes (the workspace is the checkout's path).
|
|
36
|
+
* `{{name}}` in any string is replaced; a name the manifest does not list, or a
|
|
37
|
+
* listed one with no value and no default, is refused.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveParameters(spec, values = {}) {
|
|
40
|
+
const declared = spec?.parameters ?? {};
|
|
41
|
+
const value = (name) => {
|
|
42
|
+
if (!(name in declared)) throw new Error(`{{${name}}} is not a parameter the setup declares`);
|
|
43
|
+
const v = values[name] ?? declared[name]?.default;
|
|
44
|
+
if (v === undefined || v === null) throw new Error(`parameter ${name} has no value`);
|
|
45
|
+
if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') throw new Error(`parameter ${name} must be a scalar`);
|
|
46
|
+
return String(v);
|
|
47
|
+
};
|
|
48
|
+
const walk = (node) => {
|
|
49
|
+
if (typeof node === 'string') return node.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_, name) => value(name));
|
|
50
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
51
|
+
if (node && typeof node === 'object') return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, k === 'parameters' ? v : walk(v)]));
|
|
52
|
+
return node;
|
|
53
|
+
};
|
|
54
|
+
return walk(spec);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Refuse what the package as a whole cannot mean; a single record's problem is that record's refusal. */
|
|
58
|
+
export function validate(spec) {
|
|
59
|
+
if (spec?.schema_version !== SCHEMA_VERSION) throw new Error(`schema_version ${JSON.stringify(spec?.schema_version)} is not ${SCHEMA_VERSION}`);
|
|
60
|
+
for (const key of Object.keys(spec.jobs ?? {})) if (!/^[A-Za-z0-9._ -]{1,128}$/.test(key)) throw new Error(`job key ${JSON.stringify(key)} is not a name`);
|
|
61
|
+
for (const m of spec.moved ?? []) if (!m?.from || !m?.to) throw new Error('every moved entry needs from and to');
|
|
62
|
+
for (const r of spec.removed ?? []) if (!r?.key || !['forget', 'pause', 'delete'].includes(r.then)) throw new Error('every removed entry needs a key and then: forget | pause | delete');
|
|
63
|
+
return spec;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A custody name (plan §3.4): an environment variable's name, written as Hermes's `${NAME}` reference, never its value. */
|
|
67
|
+
function custodyRef(name, what) {
|
|
68
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(String(name ?? ''))) throw new Error(`${what} must be a custody name (an environment variable's name), got ${JSON.stringify(name)}`);
|
|
69
|
+
return `\${${name}}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The Hermes config keys a package manages (plan §5, §6), grouped into the
|
|
74
|
+
* units apply writes whole:
|
|
75
|
+
* - Inference: the default pointer's `model.*` (its endpoint and key as custody
|
|
76
|
+
* references, or a literal URL and a placeholder key); the unattended
|
|
77
|
+
* pointer's `cron.model` with `cron.model_provider`, one unit that is never
|
|
78
|
+
* written half; delegation's `delegation.*`; the fallback chain;
|
|
79
|
+
* - `extensions.hermes.config`: Hermes-only settings in its own dotted names.
|
|
80
|
+
*/
|
|
81
|
+
export function hermesConfig(spec) {
|
|
82
|
+
const inf = spec.inference ?? {};
|
|
83
|
+
const pick = (name) => {
|
|
84
|
+
const m = name ? inf.models?.[name] : null;
|
|
85
|
+
if (name && !m) throw new Error(`inference pointer names ${JSON.stringify(name)}, which is not in inference.models`);
|
|
86
|
+
return m;
|
|
87
|
+
};
|
|
88
|
+
const units = [];
|
|
89
|
+
// each unit is built on its own: one bad entry refuses that unit, never the others
|
|
90
|
+
const unit = (name, build) => { try { units.push({ unit: name, values: build() }); } catch (error) { units.push({ unit: name, error: error.message }); } };
|
|
91
|
+
const d = (() => { try { return pick(inf.default); } catch (error) { units.push({ unit: 'inference.default', error: error.message }); return null; } })();
|
|
92
|
+
if (d) unit('inference.default', () => {
|
|
93
|
+
const v = { 'model.default': d.model, 'model.provider': d.provider ?? '' };
|
|
94
|
+
if (d.endpoint && d.base_url) throw new Error('a named model takes endpoint (a custody name) or base_url (a literal URL), not both');
|
|
95
|
+
if (d.endpoint) v['model.base_url'] = custodyRef(d.endpoint, 'an endpoint');
|
|
96
|
+
if (d.base_url) {
|
|
97
|
+
if (!/^https?:\/\/[^\s@?#]+$/.test(d.base_url)) throw new Error(`base_url ${JSON.stringify(d.base_url)} must be a plain http(s) URL with no credentials or query`);
|
|
98
|
+
v['model.base_url'] = d.base_url;
|
|
99
|
+
}
|
|
100
|
+
if (d.credential && d.placeholder_key) throw new Error('a named model takes credential or placeholder_key, not both');
|
|
101
|
+
if (d.credential && d.credential !== 'harness-login') v['model.api_key'] = custodyRef(d.credential, 'a credential');
|
|
102
|
+
if (d.placeholder_key) {
|
|
103
|
+
if (!/^[a-z][a-z-]{0,15}$/.test(d.placeholder_key)) throw new Error('placeholder_key is a short lowercase word, never a secret');
|
|
104
|
+
v['model.api_key'] = d.placeholder_key;
|
|
105
|
+
}
|
|
106
|
+
if (d.api_mode) v['model.api_mode'] = d.api_mode;
|
|
107
|
+
return v;
|
|
108
|
+
});
|
|
109
|
+
if (inf.unattended) unit('inference.unattended', () => {
|
|
110
|
+
const u = pick(inf.unattended);
|
|
111
|
+
// with cron.model alone, Hermes's drift guard trips (plan §5, measured): the provider is required
|
|
112
|
+
if (!u.provider) throw new Error('the unattended model needs its provider: cron.model is written with cron.model_provider, never alone');
|
|
113
|
+
return { 'cron.model': u.model, 'cron.model_provider': u.provider };
|
|
114
|
+
});
|
|
115
|
+
if (inf.delegation) unit('inference.delegation', () => { const g = pick(inf.delegation); return { 'delegation.model': g.model, 'delegation.provider': g.provider ?? '' }; });
|
|
116
|
+
if (Array.isArray(inf.fallbacks)) unit('inference.fallbacks', () => ({ fallback_providers: inf.fallbacks.map((name) => { const m = pick(name); return { provider: m.provider, model: m.model }; }) }));
|
|
117
|
+
for (const [key, value] of Object.entries(spec.extensions?.hermes?.config ?? {})) {
|
|
118
|
+
unit(`setting.${key}`, () => {
|
|
119
|
+
if (/^model(\.|$)|^cron\.model|^delegation\.(model|provider)$|^fallback_providers$/.test(key)) throw new Error(`extensions.hermes.config.${key} is Inference's to manage`);
|
|
120
|
+
return { [key]: value };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return units;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Each config unit's row: its values against the base and the live config. */
|
|
127
|
+
function diffConfig({ state, spec, live }) {
|
|
128
|
+
const rows = [];
|
|
129
|
+
const units = hermesConfig(spec);
|
|
130
|
+
const base = state?.config ?? {};
|
|
131
|
+
for (const { unit, values, error } of units) {
|
|
132
|
+
if (error) { rows.push({ key: unit, action: 'refused', detail: error }); continue; }
|
|
133
|
+
const want = canonical(values);
|
|
134
|
+
const has = canonical(Object.fromEntries(Object.keys(values).map((k) => [k, live[k] ?? null])));
|
|
135
|
+
const b = base[unit];
|
|
136
|
+
if (!b) rows.push(has === want ? { key: unit, action: 'stamp', values } : { key: unit, action: state === null ? 'plan-only' : 'act', values });
|
|
137
|
+
else {
|
|
138
|
+
const specChanged = b.declared !== want;
|
|
139
|
+
const liveChanged = b.readback !== has;
|
|
140
|
+
// Both moved to the same value (the owner set by hand what the declaration now says): agreed, not a conflict.
|
|
141
|
+
if ((!specChanged && !liveChanged) || has === want) rows.push({ key: unit, action: 'stamp', values });
|
|
142
|
+
else if (specChanged && !liveChanged) rows.push({ key: unit, action: 'act', values });
|
|
143
|
+
else rows.push({ key: unit, action: 'conflict', values, detail: specChanged ? 'both the declaration and the live value changed' : 'the live value changed (hermes config set, /model); the manager decides whether to capture it back' });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// a unit applied once and no longer declared: Hermes has no function that unsets a key, so the home keeps
|
|
147
|
+
// the value; said, like a job still live, until the manager sets it or `resolve`s the unit
|
|
148
|
+
const declared = new Set(units.map((u) => u.unit));
|
|
149
|
+
for (const unit of Object.keys(base)) {
|
|
150
|
+
if (!declared.has(unit)) rows.push({ key: unit, action: 'still-live', detail: 'applied before, no longer declared; the home keeps its value until it is set there or resolved' });
|
|
151
|
+
}
|
|
152
|
+
return rows;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function diffJobs({ state, live, spec, door }) {
|
|
156
|
+
const records = { ...(state?.records ?? {}) };
|
|
157
|
+
const rows = [];
|
|
158
|
+
// `moved` renames the base's key before anything is compared; a move that cannot happen is said
|
|
159
|
+
for (const { from, to } of spec.moved ?? []) {
|
|
160
|
+
if (records[from] && !records[to]) { records[to] = records[from]; delete records[from]; rows.push({ key: to, action: 'moved', from, detail: `was ${from}` }); }
|
|
161
|
+
else if (records[from] && records[to]) rows.push({ key: to, action: 'refused', detail: `moved from ${from}: ${to} is already owned` });
|
|
162
|
+
}
|
|
163
|
+
const byId = new Map(live.map((r) => [r.id, r]));
|
|
164
|
+
const owned = new Set(Object.values(records).map((r) => r.id));
|
|
165
|
+
const removed = new Map((spec.removed ?? []).map((r) => [r.key, r.then]));
|
|
166
|
+
const declared = spec.jobs ?? {};
|
|
167
|
+
for (const [key, job] of Object.entries(declared)) {
|
|
168
|
+
let want;
|
|
169
|
+
try {
|
|
170
|
+
// context_from names jobs by their declared keys; the harness wants its own ids
|
|
171
|
+
const refs = job.context_from === undefined ? undefined : (Array.isArray(job.context_from) ? job.context_from : [job.context_from]).map((ref) => {
|
|
172
|
+
if (ref === 'self') return ref;
|
|
173
|
+
const owner = records[ref];
|
|
174
|
+
if (!owner) throw new Error(`context_from names ${JSON.stringify(ref)}, which this home does not own yet; it applies once ${ref} exists`);
|
|
175
|
+
return owner.id;
|
|
176
|
+
});
|
|
177
|
+
want = door.fields(refs === undefined ? job : { ...job, context_from: refs }, spec.inference);
|
|
178
|
+
} catch (error) { rows.push({ key, action: 'refused', detail: error.message }); continue; }
|
|
179
|
+
const base = records[key];
|
|
180
|
+
if (!base) {
|
|
181
|
+
const carrier = live.find((r) => r.name === key && !owned.has(r.id));
|
|
182
|
+
if (carrier) rows.push({ key, action: 'unowned', id: carrier.id, detail: 'a live record carries this key; adopt it to own it' });
|
|
183
|
+
else if (state === null) rows.push({ key, action: 'plan-only', detail: 'this home has no apply state: provision or adopt it first' });
|
|
184
|
+
else rows.push({ key, action: 'create', want, enabled: job.enabled !== false });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const row = byId.get(base.id);
|
|
188
|
+
if (!row) {
|
|
189
|
+
rows.push(base.lifecycle === 'completed'
|
|
190
|
+
? { key, action: 'forget', id: base.id, detail: 'completed and gone (its lifecycle)' }
|
|
191
|
+
: { key, action: 'deleted', id: base.id, detail: 'owned, but the harness no longer has it; never re-created' });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (row.lifecycle === 'error') { rows.push({ key, action: 'harness-error', id: row.id, detail: 'the harness reports this job in error' }); continue; }
|
|
195
|
+
const specChanged = base.declared !== canonical(want);
|
|
196
|
+
const liveChanged = base.readback !== managedOf(row);
|
|
197
|
+
if (liveChanged) { rows.push({ key, action: 'conflict', id: row.id, detail: specChanged ? 'both the declaration and the live record changed' : 'the live record changed; the manager decides whether to capture it back' }); continue; }
|
|
198
|
+
if (!specChanged) { rows.push({ key, action: 'stamp', id: row.id, lifecycle: row.lifecycle, detail: row.lifecycle === 'paused' ? 'converged; the pause stands (an overlay)' : 'converged' }); continue; }
|
|
199
|
+
// act: only what differs from live, so an unchanged schedule is never re-armed
|
|
200
|
+
let patch = door.differs(want, row.managed);
|
|
201
|
+
// a field the declaration held and no longer does is cleared, never left as it was
|
|
202
|
+
const before = base.declared ? JSON.parse(base.declared) : {};
|
|
203
|
+
const dropped = Object.keys(before).filter((f) => f !== 'schedule' && !(f in want) && !(before[f] === null || before[f] === undefined));
|
|
204
|
+
const clear = Object.fromEntries(dropped.map((f) => [f, f === 'no_agent' ? false : null]));
|
|
205
|
+
let pinKept = base.pinKept ?? null;
|
|
206
|
+
// An adopted record's live model pin is kept, reported, until the declared model itself changes (plan §5)
|
|
207
|
+
const declaredModel = canonical({ model: want.model ?? null, provider: want.provider ?? null });
|
|
208
|
+
if (base.declared === null && !base.resolved && (row.managed.model || row.managed.provider) && patch.some((f) => f === 'model' || f === 'provider')) pinKept = declaredModel;
|
|
209
|
+
if (pinKept && pinKept === declaredModel) patch = patch.filter((f) => f !== 'model' && f !== 'provider');
|
|
210
|
+
else pinKept = null;
|
|
211
|
+
const changes = { ...clear, ...Object.fromEntries(patch.map((f) => [f, want[f]])) };
|
|
212
|
+
const notes = [pinKept ? `the live model pin (${row.managed.provider ?? ''}/${row.managed.model ?? ''}) is kept until the declared model changes` : null, dropped.length ? `no longer declared, cleared: ${dropped.join(', ')}` : null].filter(Boolean);
|
|
213
|
+
rows.push({ key, action: Object.keys(changes).length ? 'act' : 'stamp', id: row.id, want, patch: changes, pinKept, lifecycle: row.lifecycle, detail: notes.length ? notes.join('; ') : undefined });
|
|
214
|
+
}
|
|
215
|
+
for (const [key, base] of Object.entries(records)) {
|
|
216
|
+
if (declared[key]) continue;
|
|
217
|
+
const then = removed.get(key);
|
|
218
|
+
const row = byId.get(base.id);
|
|
219
|
+
// gone or finished already: whatever the removal asked, there is only a record to forget
|
|
220
|
+
if (then && (!row || row.lifecycle === 'completed')) rows.push({ key, action: 'forget', id: base.id, detail: `removed (${then}); the harness no longer runs it` });
|
|
221
|
+
else if (then) rows.push({ key, action: `remove:${then}`, id: base.id });
|
|
222
|
+
else if (row) rows.push({ key, action: 'still-live', id: base.id, detail: 'owned but no longer declared; declare `removed` to forget, pause or delete it' });
|
|
223
|
+
else rows.push({ key, action: 'forget', id: base.id, detail: 'owned, undeclared and gone' });
|
|
224
|
+
}
|
|
225
|
+
return rows;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The plan for one home: a row per record, and the hash that binds it to the
|
|
230
|
+
* base, the spec and the live records it read (the owned ones and any carrying
|
|
231
|
+
* a declared key; a record the agent makes meanwhile does not stale it).
|
|
232
|
+
*/
|
|
233
|
+
export async function plan({ door, homeId, spec: raw, root = stateRoot(), params = {} }) {
|
|
234
|
+
const spec = validate(resolveParameters(raw, params));
|
|
235
|
+
const state = readState(root, homeId);
|
|
236
|
+
const live = await door.list();
|
|
237
|
+
let configLive = {};
|
|
238
|
+
let configRows = [];
|
|
239
|
+
if (door.readInference && (spec.inference || spec.extensions?.hermes || Object.keys(state?.config ?? {}).length)) {
|
|
240
|
+
const units = hermesConfig(spec).filter((u) => u.values);
|
|
241
|
+
const keys = units.flatMap((u) => Object.keys(u.values));
|
|
242
|
+
configLive = keys.length ? await door.readInference(keys) : {};
|
|
243
|
+
configRows = diffConfig({ state, spec, live: configLive });
|
|
244
|
+
} else if (spec.inference && !door.readInference) {
|
|
245
|
+
configRows = [{ key: 'inference', action: 'refused', detail: `${door.target} has no door for Inference here; its worker model is the profile's` }];
|
|
246
|
+
}
|
|
247
|
+
const jobRows = diffJobs({ state, live, spec, door });
|
|
248
|
+
const owned = new Set(Object.values(state?.records ?? {}).map((r) => r.id));
|
|
249
|
+
const keys = new Set(Object.keys(spec.jobs ?? {}));
|
|
250
|
+
const seen = live.filter((r) => owned.has(r.id) || keys.has(r.name)).map((r) => ({ id: r.id, name: r.name, m: managedOf(r), l: r.lifecycle }));
|
|
251
|
+
return {
|
|
252
|
+
homeId, planOnly: state === null, rows: [...configRows, ...jobRows],
|
|
253
|
+
hash: hash({ base: state ?? null, spec, live: seen, config: configLive }),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Apply: under the home's lock, recompute the plan; refuse when a stored plan's
|
|
259
|
+
* hash no longer matches; apply config units, then creates (each behind an
|
|
260
|
+
* intent) and acts; write the new base with every verb's read-back as one
|
|
261
|
+
* generation. Reports come back as rows.
|
|
262
|
+
*/
|
|
263
|
+
export async function apply({ door, homeId, spec: raw, root = stateRoot(), planHash = null, reviewedPlan = false, params = {} }) {
|
|
264
|
+
const spec = validate(resolveParameters(raw, params));
|
|
265
|
+
const release = lock(root, homeId);
|
|
266
|
+
try {
|
|
267
|
+
const fresh = await plan({ door, homeId, spec, root });
|
|
268
|
+
if (planHash && planHash !== fresh.hash) throw new Error('the stored plan is stale: the base, the spec or the live records changed since it was made');
|
|
269
|
+
if (fresh.planOnly) return { ...fresh, applied: [] };
|
|
270
|
+
// this apply's one generation (none when it changes nothing); every verb after it is written in place,
|
|
271
|
+
// so a crash loses no record
|
|
272
|
+
const changes = readIntents(root, homeId).length || fresh.rows.some((r) => ['create', 'act', 'moved', 'forget', 'remove:forget', 'remove:pause', 'remove:delete'].includes(r.action)); // config units' acts included
|
|
273
|
+
const state = changes ? writeState(root, homeId, readState(root, homeId)) : readState(root, homeId);
|
|
274
|
+
state.records ??= {};
|
|
275
|
+
state.config ??= {};
|
|
276
|
+
const applied = await recoverIntents({ state, door, spec, root, homeId });
|
|
277
|
+
const save = () => writeState(root, homeId, state, { generation: false });
|
|
278
|
+
for (const row of fresh.rows) {
|
|
279
|
+
try {
|
|
280
|
+
if (row.key.startsWith('inference.') || row.key.startsWith('setting.')) {
|
|
281
|
+
if (row.action === 'act') {
|
|
282
|
+
await door.writeInference(row.values);
|
|
283
|
+
const back = await door.readInference(Object.keys(row.values));
|
|
284
|
+
const has = canonical(Object.fromEntries(Object.keys(row.values).map((k) => [k, back[k] ?? null])));
|
|
285
|
+
state.config[row.key] = { declared: canonical(row.values), readback: has };
|
|
286
|
+
// a managed scope, or Hermes stripping a default, can leave the value other than declared: said, not assumed
|
|
287
|
+
applied.push(has === canonical(row.values) ? { key: row.key, action: 'updated' } : { key: row.key, action: 'not-applied', detail: 'the harness reads back a different value (a managed scope, or a default it strips)' });
|
|
288
|
+
} else if (row.action === 'stamp') {
|
|
289
|
+
state.config[row.key] = { declared: canonical(row.values), readback: canonical(row.values) };
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (row.action === 'moved') {
|
|
294
|
+
if (state.records[row.from] && !state.records[row.key]) { state.records[row.key] = state.records[row.from]; delete state.records[row.from]; }
|
|
295
|
+
} else if (row.action === 'create') {
|
|
296
|
+
const intents = readIntents(root, homeId);
|
|
297
|
+
writeIntents(root, homeId, [...intents, { key: row.key, at: new Date().toISOString(), declared: canonical(row.want), want: row.want, enabled: row.enabled }]);
|
|
298
|
+
let made = await door.create(row.key, row.want);
|
|
299
|
+
// create then pause is one intent: the intent stays until the pause is done
|
|
300
|
+
if (!row.enabled) made = await door.pause(made.id);
|
|
301
|
+
state.records[row.key] = { id: made.id, declared: canonical(row.want), readback: managedOf(made), lifecycle: made.lifecycle };
|
|
302
|
+
save();
|
|
303
|
+
writeIntents(root, homeId, readIntents(root, homeId).filter((i) => i.key !== row.key));
|
|
304
|
+
applied.push({ key: row.key, action: 'created', id: made.id });
|
|
305
|
+
} else if (row.action === 'act') {
|
|
306
|
+
const after = await door.update(row.id, row.patch);
|
|
307
|
+
state.records[row.key] = { id: after.id, declared: canonical(row.want), readback: managedOf(after), lifecycle: after.lifecycle, ...(row.pinKept ? { pinKept: row.pinKept } : {}) };
|
|
308
|
+
// what was sent is checked against what the harness now holds: a field it rewrote is said, not assumed
|
|
309
|
+
const left = door.differs(row.patch, after.managed);
|
|
310
|
+
applied.push(left.length
|
|
311
|
+
? { key: row.key, action: 'not-applied', id: after.id, detail: `the harness holds other values for ${left.join(', ')} than were sent` }
|
|
312
|
+
: { key: row.key, action: 'updated', id: after.id, detail: Object.keys(row.patch).join(', ') });
|
|
313
|
+
} else if (row.action === 'stamp' && row.id && state.records[row.key]) {
|
|
314
|
+
state.records[row.key].lifecycle = row.lifecycle ?? state.records[row.key].lifecycle;
|
|
315
|
+
if (row.want) { state.records[row.key].declared = canonical(row.want); if (row.pinKept) state.records[row.key].pinKept = row.pinKept; }
|
|
316
|
+
} else if (row.action === 'forget' || row.action === 'remove:forget') {
|
|
317
|
+
delete state.records[row.key];
|
|
318
|
+
applied.push({ key: row.key, action: 'forgotten', id: row.id });
|
|
319
|
+
} else if (row.action === 'remove:pause') {
|
|
320
|
+
await door.pause(row.id);
|
|
321
|
+
delete state.records[row.key];
|
|
322
|
+
applied.push({ key: row.key, action: 'paused and forgotten', id: row.id });
|
|
323
|
+
} else if (row.action === 'remove:delete') {
|
|
324
|
+
// only through the plan the manager reviewed (never at start), and only after the record's
|
|
325
|
+
// durable data is captured beside the applier's state
|
|
326
|
+
if (!reviewedPlan || !planHash) { applied.push({ key: row.key, action: 'refused', detail: 'delete runs only through a reviewed, stored plan (--plan-hash with --reviewed)' }); continue; }
|
|
327
|
+
const captured = writeCapture(root, homeId, row.key, row.id, await door.capture(row.id));
|
|
328
|
+
await door.remove(row.id);
|
|
329
|
+
delete state.records[row.key];
|
|
330
|
+
applied.push({ key: row.key, action: 'deleted', id: row.id, detail: `durable data captured in ${captured}` });
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
// refusal is per record: one record's failure never stops the others
|
|
334
|
+
applied.push({ key: row.key, action: 'refused', detail: error.message });
|
|
335
|
+
}
|
|
336
|
+
save();
|
|
337
|
+
}
|
|
338
|
+
return { ...fresh, applied };
|
|
339
|
+
} finally {
|
|
340
|
+
release();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A crash between a create's verb and the base's write leaves an intent. It is
|
|
346
|
+
* the applier's own act only when exactly one unowned live record carries the
|
|
347
|
+
* key, the declared values and a creation time inside the intent's window;
|
|
348
|
+
* then an intended pause is finished. Anything else stays an intent, reported.
|
|
349
|
+
*/
|
|
350
|
+
async function recoverIntents({ state, door, spec, root, homeId }) {
|
|
351
|
+
const intents = readIntents(root, homeId);
|
|
352
|
+
if (!intents.length) return [];
|
|
353
|
+
const live = await door.list();
|
|
354
|
+
const owned = new Set(Object.values(state.records).map((r) => r.id));
|
|
355
|
+
const left = [];
|
|
356
|
+
const report = [];
|
|
357
|
+
for (const intent of intents) {
|
|
358
|
+
const at = Date.parse(intent.at);
|
|
359
|
+
const job = spec.jobs?.[intent.key];
|
|
360
|
+
// the values the create was sent (references already resolved to ids), as the intent recorded them
|
|
361
|
+
const want = job ? intent.want ?? null : null;
|
|
362
|
+
// compared on what Hermes stores as given (paths and references it normalizes are left out)
|
|
363
|
+
const core = want ? Object.fromEntries(Object.entries(want).filter(([f]) => ['schedule', 'prompt', 'skills', 'script', 'no_agent'].includes(f))) : null;
|
|
364
|
+
const matches = live.filter((r) => !owned.has(r.id) && r.name === intent.key && r.created_at
|
|
365
|
+
&& Math.abs(Date.parse(r.created_at) - at) <= INTENT_WINDOW_MS && core && door.differs(core, r.managed).length === 0);
|
|
366
|
+
if (matches.length === 1 && want && canonical(want) === intent.declared) {
|
|
367
|
+
let row = matches[0];
|
|
368
|
+
if (intent.enabled === false && row.lifecycle !== 'paused') row = await door.pause(row.id);
|
|
369
|
+
state.records[intent.key] = { id: row.id, declared: intent.declared, readback: managedOf(row), lifecycle: row.lifecycle };
|
|
370
|
+
owned.add(row.id);
|
|
371
|
+
report.push({ key: intent.key, action: 'recovered', id: row.id, detail: 'a create interrupted before its record was written' });
|
|
372
|
+
} else if (!matches.length && (!job || Date.now() - at > INTENT_WINDOW_MS)) {
|
|
373
|
+
// nothing was made (the harness refused it) or the key is no longer declared: nothing to recover
|
|
374
|
+
report.push({ key: intent.key, action: 'intent-dropped', detail: 'an interrupted create left no record to recover' });
|
|
375
|
+
} else {
|
|
376
|
+
left.push(intent);
|
|
377
|
+
report.push({ key: intent.key, action: 'intent-unresolved', detail: `${matches.length} live record(s) match an interrupted create; reported, not assumed` });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
writeIntents(root, homeId, left);
|
|
381
|
+
return report;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Resolve a conflict (a per-home act, the manager's): the live record becomes
|
|
386
|
+
* the base. The next apply then either restores the declaration over it (the
|
|
387
|
+
* manager chose the package) or finds nothing to change (the manager captured
|
|
388
|
+
* the live value back into the package first). Config units likewise.
|
|
389
|
+
*/
|
|
390
|
+
export async function resolve({ door, homeId, keys, root = stateRoot() }) {
|
|
391
|
+
const release = lock(root, homeId);
|
|
392
|
+
try {
|
|
393
|
+
const state = readState(root, homeId);
|
|
394
|
+
if (!state) throw new Error(`home ${homeId} has no apply state; there is nothing to resolve`);
|
|
395
|
+
const live = await door.list();
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const key of keys) {
|
|
398
|
+
if (state.config?.[key]) { delete state.config[key]; out.push({ key, action: 'resolved' }); continue; }
|
|
399
|
+
const base = state.records?.[key];
|
|
400
|
+
const row = base && live.find((r) => r.id === base.id);
|
|
401
|
+
if (!row) { out.push({ key, action: 'refused', detail: 'not an owned record with a live counterpart' }); continue; }
|
|
402
|
+
state.records[key] = { id: base.id, declared: null, readback: managedOf(row), lifecycle: row.lifecycle, resolved: true };
|
|
403
|
+
out.push({ key, action: 'resolved', id: row.id });
|
|
404
|
+
}
|
|
405
|
+
writeState(root, homeId, state);
|
|
406
|
+
return out;
|
|
407
|
+
} finally {
|
|
408
|
+
release();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Adopt (a per-home act): the named live records become owned, their base the live read-back. */
|
|
413
|
+
export async function adopt({ door, homeId, keys, root = stateRoot() }) {
|
|
414
|
+
const release = lock(root, homeId);
|
|
415
|
+
try {
|
|
416
|
+
const state = readState(root, homeId) ?? { records: {}, generation: 0 };
|
|
417
|
+
state.records ??= {};
|
|
418
|
+
const live = await door.list();
|
|
419
|
+
const owned = new Set(Object.values(state.records).map((r) => r.id));
|
|
420
|
+
const adopted = [];
|
|
421
|
+
for (const key of keys) {
|
|
422
|
+
const candidates = live.filter((r) => r.name === key && !owned.has(r.id));
|
|
423
|
+
if (candidates.length !== 1) { adopted.push({ key, action: 'refused', detail: `${candidates.length} unowned live record(s) carry this key` }); continue; }
|
|
424
|
+
const row = candidates[0];
|
|
425
|
+
// declared is left unset, so the next apply acts: only the fields that differ, a live pin kept
|
|
426
|
+
state.records[key] = { id: row.id, declared: null, readback: managedOf(row), lifecycle: row.lifecycle };
|
|
427
|
+
owned.add(row.id);
|
|
428
|
+
adopted.push({ key, action: 'adopted', id: row.id });
|
|
429
|
+
}
|
|
430
|
+
writeState(root, homeId, state);
|
|
431
|
+
return adopted;
|
|
432
|
+
} finally {
|
|
433
|
+
release();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Provisioning: the runtime's own new home gets an empty state, so its first apply creates. */
|
|
438
|
+
export function provision({ homeId, root = stateRoot() }) {
|
|
439
|
+
const release = lock(root, homeId);
|
|
440
|
+
try { return provisionState(root, homeId); } finally { release(); }
|
|
441
|
+
}
|