@volter/supercode-interchange 0.1.22 → 0.1.24
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/LICENSE-diffy-MIT +23 -0
- package/README.md +422 -2
- package/channel-records.mjs +71 -0
- package/channel-session-counts.mjs +81 -0
- package/channels-read.mjs +90 -0
- package/corpus-audit-records.mjs +192 -0
- package/corpus-audit-render.mjs +47 -0
- package/corpus-audit-schema.mjs +89 -0
- package/corpus-audit.d.ts +16 -0
- package/corpus-audit.mjs +132 -0
- package/file-io.mjs +21 -2
- package/harness-settings.mjs +19 -0
- package/hermes-fire-links.mjs +135 -0
- package/hermes-fires.mjs +106 -0
- package/hermes-read-home.d.ts +3 -0
- package/hermes-read-home.mjs +235 -0
- package/hermes-read-store.mjs +49 -0
- package/hermes-read-validation.mjs +170 -0
- package/hermes-read-workflow.mjs +164 -0
- package/hermes-read-yaml.mjs +68 -0
- package/hermes-store.mjs +1 -1
- package/homes.mjs +2 -2
- package/index.d.ts +14 -1
- package/index.mjs +1 -1
- package/inspection-builtin-tools.json +261 -0
- package/inspection-config-fields.json +765 -0
- package/inspection-schemas.d.ts +4 -0
- package/inspection-schemas.mjs +117 -0
- package/job-notepad-read.mjs +97 -0
- package/jobs-control.mjs +250 -0
- package/jobs-read.mjs +198 -0
- package/jsonl.mjs +4 -3
- package/memory-show.mjs +187 -0
- package/metadata.mjs +45 -3
- package/native-json.mjs +46 -3
- package/ontology-contracts.d.ts +5 -0
- package/ontology-contracts.mjs +29 -0
- package/ontology-operations.json +198 -0
- package/ontology-schemas.json +7946 -0
- package/openclaw-jobs-read.mjs +117 -0
- package/openclaw-profile-home.mjs +142 -0
- package/orchestrator-read-records.mjs +112 -0
- package/orchestrator-store.mjs +21 -9
- package/package.json +59 -4
- package/parity-ledger.json +6984 -0
- package/parity-orchestration.json +5538 -0
- package/parity-report.d.ts +7 -0
- package/parity-report.mjs +46 -0
- package/preset-profiles.mjs +27 -0
- package/profile-command.mjs +32 -0
- package/profile-orchestrator-door.mjs +104 -0
- package/profile-paths.mjs +25 -0
- package/profiles-control.mjs +98 -0
- package/profiles-read.mjs +119 -0
- package/reduction-attribution.mjs +54 -0
- package/reduction-diff.mjs +165 -0
- package/reduction-log.mjs +177 -0
- package/reduction-view.mjs +145 -0
- package/retained-projection.mjs +9 -0
- package/routes-read.mjs +103 -0
- package/runs-read.mjs +224 -0
- package/session-activity.d.ts +23 -0
- package/session-activity.mjs +124 -0
- package/session-append-scan.mjs +27 -17
- package/session-branch-handoff.mjs +115 -0
- package/session-claude-graph.mjs +29 -7
- package/session-claude-index.mjs +732 -114
- package/session-claude-runtime.d.ts +28 -0
- package/session-claude-runtime.mjs +171 -6
- package/session-claude-scan-store.mjs +164 -0
- package/session-claude.mjs +9 -0
- package/session-codex-activity.d.ts +6 -0
- package/session-codex-activity.mjs +120 -0
- package/session-codex-window.mjs +24 -4
- package/session-config-scan.mjs +141 -0
- package/session-convert.d.ts +33 -0
- package/session-convert.mjs +179 -0
- package/session-emission-version.json +1 -0
- package/session-file.mjs +11 -1
- package/session-follower.mjs +17 -5
- package/session-fresh-id.mjs +15 -0
- package/session-hermes-import.mjs +61 -0
- package/session-hermes.mjs +1 -1
- package/session-import.mjs +37 -0
- package/session-inspect.d.ts +18 -0
- package/session-inspect.mjs +122 -0
- package/session-machine-tree.d.ts +33 -0
- package/session-machine-tree.mjs +274 -0
- package/session-materialize-files.mjs +78 -0
- package/session-materialize-opencode.mjs +137 -0
- package/session-materialize.mjs +186 -0
- package/session-message.mjs +85 -0
- package/session-model.d.ts +9 -0
- package/session-model.mjs +1 -0
- package/session-opencode-store.mjs +39 -3
- package/session-path.mjs +6 -3
- package/session-pi-content.mjs +7 -2
- package/session-read.d.ts +134 -2
- package/session-read.mjs +86 -9
- package/session-recorded-config.mjs +3 -2
- package/session-records.mjs +12 -6
- package/session-residue-store.mjs +90 -0
- package/session-residue.mjs +150 -0
- package/session-resume-instructions.mjs +1 -1
- package/session-saved-read.mjs +105 -0
- package/session-transfer.mjs +141 -0
- package/session-views.mjs +1 -0
- package/session-write-claude.mjs +135 -0
- package/session-write-codex.mjs +146 -0
- package/session-write-gemini.mjs +77 -0
- package/session-write-goose.mjs +99 -0
- package/session-write-grok.mjs +70 -0
- package/session-write-native.mjs +41 -0
- package/session-write-opencode.mjs +197 -0
- package/session-write-pi.mjs +130 -0
- package/session-write-residue.mjs +19 -0
- package/session-write-utils.mjs +77 -0
- package/session-write.d.ts +10 -0
- package/session-write.mjs +37 -0
- package/skill-toml.mjs +80 -0
- package/skills-control.mjs +193 -0
- package/skills-list.mjs +237 -0
- package/source-files.mjs +12 -2
- package/sqlite-reader.mjs +134 -18
- package/support-registry.json +402 -0
- package/support-report.mjs +69 -0
- package/trigger-records.mjs +77 -0
- package/triggers-read.mjs +67 -0
- package/workflow-read.mjs +165 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Reads the complete typed config file layers. Never resolves credentials or runs hooks. */
|
|
2
|
+
export function inspectionSchemaTier(context?: { tier?: 'full' | 'medium' | 'minimal' | null; cwd?: string; env?: Record<string, string | undefined>; diagnostic?: (text: string) => void }): 'full' | 'medium' | 'minimal';
|
|
3
|
+
export function inspectionToolSchemas(tier?: 'full' | 'medium' | 'minimal'): Array<{ name: string; description: string; parameters: object }>;
|
|
4
|
+
export function inspectionSchemaStats(tier: 'full' | 'medium' | 'minimal'): { tier: string; fullTokens: bigint; tierTokens: bigint; pct: bigint };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Schema cost from the same builtin declarations, without constructing tools
|
|
2
|
+
// or an agent. Configuration is admitted as a whole before its tier is used.
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
6
|
+
import { parseSkillToml } from './skill-toml.mjs';
|
|
7
|
+
import { decodeSessionUtf8 } from './session-file.mjs';
|
|
8
|
+
import { cloneNativeJson, stringifyNativeJson } from './native-json.mjs';
|
|
9
|
+
import { inspectionPercentage } from './session-inspect.mjs';
|
|
10
|
+
import fields from './inspection-config-fields.json' with { type: 'json' };
|
|
11
|
+
import builtins from './inspection-builtin-tools.json' with { type: 'json' };
|
|
12
|
+
|
|
13
|
+
const object = value => value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
|
|
14
|
+
function admit(value, schema) {
|
|
15
|
+
if (typeof schema === 'string') {
|
|
16
|
+
if (schema === 'serde_json::Value') return true;
|
|
17
|
+
if (schema === 'String') return typeof value === 'string';
|
|
18
|
+
if (schema === 'bool') return typeof value === 'boolean';
|
|
19
|
+
if (schema === 'f32' || schema === 'f64') return typeof value === 'number' || typeof value === 'bigint';
|
|
20
|
+
const bits = schema === 'usize' ? 64 : Number(schema.slice(1)), unsigned = schema[0] === 'u';
|
|
21
|
+
return typeof value === 'bigint' && value >= (unsigned ? 0n : -(1n << BigInt(bits - 1)))
|
|
22
|
+
&& value <= (1n << BigInt(unsigned ? bits : bits - 1)) - 1n;
|
|
23
|
+
}
|
|
24
|
+
if (schema.array) return Array.isArray(value) && value.every(member => admit(member, schema.array));
|
|
25
|
+
if (schema.map) return object(value) && Object.values(value).every(member => admit(member, schema.map));
|
|
26
|
+
if (!object(value)) return false;
|
|
27
|
+
const definition = fields.definitions[schema.record];
|
|
28
|
+
for (const [key, field] of Object.entries(definition)) {
|
|
29
|
+
if (field.flatten) continue;
|
|
30
|
+
if (!Object.hasOwn(value, key)) { if (field.required) return false; }
|
|
31
|
+
else if (!admit(value[key], field.schema)) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const exists = path => { try { statSync(path); return true; } catch { return false; } };
|
|
36
|
+
const file = path => { try { return statSync(path).isFile(); } catch { return false; } };
|
|
37
|
+
function configHome(env) {
|
|
38
|
+
if (env.SUPERCODE_HOME) return env.SUPERCODE_HOME;
|
|
39
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'supercode');
|
|
40
|
+
let home = env.HOME || (process.platform === 'win32' && env.USERPROFILE);
|
|
41
|
+
if (!home) try { home = userInfo().homedir; } catch { home = '.'; }
|
|
42
|
+
return join(home, '.config', 'supercode');
|
|
43
|
+
}
|
|
44
|
+
export function inspectionSchemaTier({ tier = null, cwd = process.cwd(), env = process.env, diagnostic = () => {} } = {}) {
|
|
45
|
+
// Even an explicit tier follows the native file-reading/warning behavior.
|
|
46
|
+
const read = path => {
|
|
47
|
+
let bytes;
|
|
48
|
+
try { bytes = readFileSync(path); }
|
|
49
|
+
catch (error) { if (error.code !== 'ENOENT') diagnostic(`warning: failed to read config \`${path}\` (${error.code ?? 'filesystem error'}) — ignoring this file`); return {}; }
|
|
50
|
+
try {
|
|
51
|
+
const value = parseSkillToml(decodeSessionUtf8(bytes));
|
|
52
|
+
if (!admit(value, { record: fields.root })) throw new Error('typed configuration');
|
|
53
|
+
return value;
|
|
54
|
+
} catch { diagnostic(`warning: failed to parse config \`${path}\` (TOML syntax/schema error) — ignoring this file`); return {}; }
|
|
55
|
+
};
|
|
56
|
+
const user = read(join(configHome(env), 'config.toml')), markers = user.core?.project_root_markers ?? ['.git'];
|
|
57
|
+
let root = null, at = cwd;
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (markers.some(marker => marker !== '' && exists(join(at, marker)))) { root = at; break; }
|
|
60
|
+
const parent = dirname(at); if (parent === at) break; at = parent;
|
|
61
|
+
}
|
|
62
|
+
let value = user.schema_tier;
|
|
63
|
+
for (const name of ['.supercode.toml', '.supercode.local.toml']) {
|
|
64
|
+
at = cwd;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const path = join(at, name);
|
|
67
|
+
if (file(path)) { const layer = read(path); if (layer.schema_tier != null) value = layer.schema_tier; break; }
|
|
68
|
+
if (at === root) break;
|
|
69
|
+
const parent = dirname(at); if (parent === at) break; at = parent;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return tier ?? (['full', 'medium', 'minimal'].includes(value) ? value : 'full');
|
|
73
|
+
}
|
|
74
|
+
const abbreviations = ['e.g.', 'i.e.', 'etc.', 'Mr.', 'Mrs.', 'Dr.', 'vs.', 'cf.'];
|
|
75
|
+
function sentences(text, maximum) {
|
|
76
|
+
let count = 0;
|
|
77
|
+
for (let at = 0; at < text.length; at++) {
|
|
78
|
+
if (!'.!?'.includes(text[at]) || at + 1 < text.length && ![' ', '\n'].includes(text[at + 1])) continue;
|
|
79
|
+
if (text[at] === '.' && abbreviations.some(value => text.slice(0, at + 1).endsWith(value))) continue;
|
|
80
|
+
if (++count === maximum) return text.slice(0, at + 1);
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
function minifyNode(node, tier) {
|
|
85
|
+
if (!object(node)) return;
|
|
86
|
+
delete node.examples; delete node.title;
|
|
87
|
+
if (typeof node.description === 'string') node.description = sentences(node.description, tier === 'minimal' ? 1 : 2);
|
|
88
|
+
const required = Array.isArray(node.required) ? node.required.filter(key => typeof key === 'string') : [];
|
|
89
|
+
if (object(node.properties)) for (const [key, child] of Object.entries(node.properties)) {
|
|
90
|
+
if (object(child)) {
|
|
91
|
+
delete child.examples; delete child.title;
|
|
92
|
+
if (tier === 'minimal' && !required.includes(key)) delete child.description;
|
|
93
|
+
else if (typeof child.description === 'string') child.description = sentences(child.description, 1);
|
|
94
|
+
}
|
|
95
|
+
minifyNode(child, tier);
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(node.items)) node.items.forEach(child => minifyNode(child, tier));
|
|
98
|
+
else minifyNode(node.items, tier);
|
|
99
|
+
for (const key of ['anyOf', 'oneOf', 'allOf']) if (Array.isArray(node[key])) node[key].forEach(child => minifyNode(child, tier));
|
|
100
|
+
for (const key of ['if', 'then', 'else']) minifyNode(node[key], tier);
|
|
101
|
+
for (const key of ['$defs', 'definitions', 'patternProperties']) if (object(node[key])) Object.values(node[key]).forEach(child => minifyNode(child, tier));
|
|
102
|
+
}
|
|
103
|
+
export function inspectionToolSchemas(tier = 'full') {
|
|
104
|
+
if (!['full', 'medium', 'minimal'].includes(tier)) throw new TypeError('schema tier must be full, medium or minimal');
|
|
105
|
+
return builtins.map(schema => {
|
|
106
|
+
const full = cloneNativeJson(schema); if (tier === 'full') return full;
|
|
107
|
+
const description = sentences(full.description, tier === 'minimal' ? 1 : 2), parameters = cloneNativeJson(full.parameters);
|
|
108
|
+
minifyNode(parameters, tier);
|
|
109
|
+
const size = (description, parameters) => Buffer.byteLength(description) + Buffer.byteLength(stringifyNativeJson(parameters));
|
|
110
|
+
return size(description, parameters) >= size(full.description, full.parameters) ? full : { name: full.name, description, parameters };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export function inspectionSchemaStats(tier) {
|
|
114
|
+
const tokens = schemas => BigInt(Math.ceil(Buffer.byteLength(stringifyNativeJson(schemas)) / 4));
|
|
115
|
+
const fullTokens = tokens(inspectionToolSchemas('full')), tierTokens = tokens(inspectionToolSchemas(tier));
|
|
116
|
+
return { tier, fullTokens, tierTokens, pct: inspectionPercentage(tierTokens, fullTokens) };
|
|
117
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// A job's recorded notepad. The selected store is opened read-only, after
|
|
2
|
+
// the same complete job-file validation and first-id/profile choice as jobs.get.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { resolveHarnessHomes } from './homes.mjs';
|
|
7
|
+
import { parseNativeJson } from './native-json.mjs';
|
|
8
|
+
import { hermesJobRecords } from './hermes-read-validation.mjs';
|
|
9
|
+
import { childPath } from './session-spend-files.mjs';
|
|
10
|
+
import { profilePathJoin as join, profilePathParent as parent } from './profile-paths.mjs';
|
|
11
|
+
import { nativeTrim } from './text.mjs';
|
|
12
|
+
|
|
13
|
+
const jobHarnesses = ['claude-code', 'hermes', 'openclaw', 'orchestrator'];
|
|
14
|
+
const object = value => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
16
|
+
const compare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
17
|
+
const refuse = (message, rpcCode = -32602) => { throw Object.assign(new Error(message), { rpcCode }); };
|
|
18
|
+
const directory = path => { try { return statSync(path).isDirectory(); } catch { return false; } };
|
|
19
|
+
const filesystem = (path, cwd) => !path.length || isAbsolute(path) || process.platform === 'win32' && /^[A-Za-z]:/u.test(path) ? path : join(cwd, path);
|
|
20
|
+
function struct(value, fields, name) {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
if (value.length > fields.length) refuse(`${name} has too many fields`);
|
|
23
|
+
return Object.fromEntries(value.map((value, at) => [fields[at], value]));
|
|
24
|
+
}
|
|
25
|
+
if (!object(value)) refuse(`${name} must be a struct`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function query(params, context) {
|
|
29
|
+
const query = struct(params, ['harness', 'id', 'key', 'value', 'profile', 'homes'], 'notepad params');
|
|
30
|
+
for (const key of ['harness', 'id']) {
|
|
31
|
+
if (Object.hasOwn(query, key) && typeof query[key] !== 'string') refuse(`${key} must be a string`);
|
|
32
|
+
}
|
|
33
|
+
for (const key of ['key', 'value', 'profile']) if (query[key] != null && typeof query[key] !== 'string') refuse(`${key} must be a string`);
|
|
34
|
+
const homes = resolveHarnessHomes(context.env);
|
|
35
|
+
if (Object.hasOwn(query, 'homes')) {
|
|
36
|
+
const supplied = struct(query.homes, Object.keys(homes), 'homes');
|
|
37
|
+
for (const key of Object.keys(homes)) if (Object.hasOwn(supplied, key)) {
|
|
38
|
+
if (typeof supplied[key] !== 'string') refuse(`homes.${key} must be a string`);
|
|
39
|
+
homes[key] = supplied[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { ...query, harness: query.harness ?? '', id: query.id ?? '', homes };
|
|
43
|
+
}
|
|
44
|
+
function jobStores(root) {
|
|
45
|
+
const stores = [{ path: join(root, 'cron/jobs.json'), profile: null }], profiles = join(root, 'profiles');
|
|
46
|
+
try {
|
|
47
|
+
const named = readdirSync(profiles, { encoding: 'buffer' }).filter(name => {
|
|
48
|
+
const text = name.toString('utf8');
|
|
49
|
+
return text !== 'node_modules' && !text.startsWith('.') && directory(childPath(profiles, name));
|
|
50
|
+
}).map(name => ({ path: childPath(childPath(profiles, name), 'cron/jobs.json'), profile: name.toString('utf8') }));
|
|
51
|
+
named.sort((a, b) => compare(a.profile, b.profile)); stores.push(...named);
|
|
52
|
+
} catch { /* an unread profile directory contributes no stores */ }
|
|
53
|
+
return stores;
|
|
54
|
+
}
|
|
55
|
+
function requireJob(selected, root) {
|
|
56
|
+
for (const store of jobStores(root)) {
|
|
57
|
+
let jobs;
|
|
58
|
+
try { jobs = hermesJobRecords(String(store.path), parseNativeJson(utf8.decode(readFileSync(store.path)))); }
|
|
59
|
+
catch { continue; } // One invalid record invalidates that entire job file.
|
|
60
|
+
if (!jobs.has(selected.id)) continue;
|
|
61
|
+
if (store.profile === (selected.profile ?? null)) return;
|
|
62
|
+
refuse(`job \`${selected.id}\` belongs to ${store.profile == null ? 'the root home' : `profile \`${store.profile}\``}; name that profile with \`profile\``);
|
|
63
|
+
}
|
|
64
|
+
refuse(`${selected.harness} has no job \`${selected.id}\``);
|
|
65
|
+
}
|
|
66
|
+
function readEntries(path, id, key) {
|
|
67
|
+
let db;
|
|
68
|
+
try {
|
|
69
|
+
db = new DatabaseSync(path, { readOnly: true, allowExtension: false });
|
|
70
|
+
db.exec('PRAGMA busy_timeout=5000');
|
|
71
|
+
// Read TEXT as bytes so malformed UTF-8 is an error, as rusqlite's String
|
|
72
|
+
// conversion is. SQLite BLOB/NULL/numeric cells remain type errors.
|
|
73
|
+
const rows = db.prepare(`SELECT typeof(key) AS kt, CAST(key AS BLOB) AS kb,
|
|
74
|
+
typeof(value) AS vt, CAST(value AS BLOB) AS vb,
|
|
75
|
+
typeof(updated_at) AS ut, CAST(updated_at AS BLOB) AS ub
|
|
76
|
+
FROM cron_notepad WHERE job_id = ? AND (? IS NULL OR key = ?) ORDER BY key`).all(id, key, key);
|
|
77
|
+
return rows.map(row => {
|
|
78
|
+
const entry = {};
|
|
79
|
+
for (const [name, prefix] of [['key', 'k'], ['value', 'v'], ['updated_at', 'u']]) {
|
|
80
|
+
if (row[prefix + 't'] !== 'text') throw new Error(`${name} is not SQLite TEXT`);
|
|
81
|
+
entry[name] = utf8.decode(row[prefix + 'b']);
|
|
82
|
+
}
|
|
83
|
+
return entry;
|
|
84
|
+
});
|
|
85
|
+
} catch (error) { refuse(`${path}: ${error.message}`, -32000); }
|
|
86
|
+
finally { db?.close(); }
|
|
87
|
+
}
|
|
88
|
+
export function readJobNotepad(params, { env = process.env, cwd = process.cwd() } = {}) {
|
|
89
|
+
const selected = query(params, { env, cwd });
|
|
90
|
+
if (!jobHarnesses.includes(selected.harness)) refuse(`\`${selected.harness}\` has no scheduled jobs; \`jobs.notepad\` is supported for: ${jobHarnesses.join(', ')}`, -32020);
|
|
91
|
+
if (!nativeTrim(selected.id)) refuse('a notepad call needs the job id');
|
|
92
|
+
if (selected.harness !== 'hermes') refuse(`\`${selected.harness}\` keeps no job notepad; the notepad is supported for: hermes`, -32020);
|
|
93
|
+
const root = parent(selected.homes.hermes) ?? '.', actualRoot = filesystem(root, cwd);
|
|
94
|
+
requireJob(selected, actualRoot);
|
|
95
|
+
const home = selected.profile == null ? root : join(join(root, 'profiles'), selected.profile), store = join(home, 'cron/notepad.db'), path = filesystem(store, cwd);
|
|
96
|
+
return { harness: selected.harness, id: selected.id, entries: existsSync(path) ? readEntries(path, selected.id, selected.key ?? null) : [] };
|
|
97
|
+
}
|
package/jobs-control.mjs
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Scheduled-job writes belong to each harness's own command/operator. The
|
|
2
|
+
// existing observed reader, never a command's echo, establishes the outcome.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { resolveHarnessHomes } from './homes.mjs';
|
|
6
|
+
import { listJobs, jobFloat, JOB_HARNESSES } from './jobs-read.mjs';
|
|
7
|
+
import { readJobNotepad } from './job-notepad-read.mjs';
|
|
8
|
+
import { profilePathJoin as join, profilePathParent as parent } from './profile-paths.mjs';
|
|
9
|
+
import { nativeTrim } from './text.mjs';
|
|
10
|
+
import { nativeInteger, nativeNumberKind, parseNativeJson, setNativeFloat, stringifyNativeJsonMember } from './native-json.mjs';
|
|
11
|
+
import { supportRegistryReport } from './support-report.mjs';
|
|
12
|
+
import { callProfileOrchestrator } from './profile-orchestrator-door.mjs';
|
|
13
|
+
import { profileCommandFailure as failed, profileShellQuote as quote, runProfileCommand, requireProfileCommandSuccess } from './profile-command.mjs';
|
|
14
|
+
|
|
15
|
+
export const CONTROLLED_JOB_HARNESSES = Object.freeze(['hermes', 'openclaw', 'orchestrator']);
|
|
16
|
+
const verbs = ['create', 'update', 'pause', 'resume', 'run', 'delete'];
|
|
17
|
+
const invalid = message => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
|
|
18
|
+
const unsupported = message => { throw Object.assign(new Error(message), { rpcCode: -32020 }); };
|
|
19
|
+
const display = path => Buffer.isBuffer(path) ? path.toString('utf8') : path;
|
|
20
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
21
|
+
const compare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
22
|
+
const mutationFields = ['harness', 'id', 'name', 'schedule', 'payload', 'session_target', 'deliver', 'profile', 'skills', 'workdir', 'model', 'provider', 'repeat', 'homes'];
|
|
23
|
+
function struct(value, fields, name, strict = false) {
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (value.length > fields.length) invalid(`${name} has too many fields`);
|
|
26
|
+
return parseNativeJson(`{${value.map((_, at) => `${JSON.stringify(fields[at])}:${stringifyNativeJsonMember(value, String(at))}`).join(',')}}`);
|
|
27
|
+
}
|
|
28
|
+
if (value == null || typeof value !== 'object') invalid(`${name} must be a struct`);
|
|
29
|
+
if (strict) for (const key of Object.keys(value)) if (!fields.includes(key)) invalid(`unknown field \`${key}\` in ${name}`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function strings(value, keys, name) {
|
|
33
|
+
for (const key of keys) if (value[key] != null && typeof value[key] !== 'string') invalid(`${name}${key} must be a string`);
|
|
34
|
+
}
|
|
35
|
+
function selectedHomes(value, context) {
|
|
36
|
+
const homes = Object.fromEntries(Object.entries(resolveHarnessHomes(context.env)).map(([key, path]) => [key, display(path)]));
|
|
37
|
+
if (Object.hasOwn(value, 'homes')) {
|
|
38
|
+
const supplied = struct(value.homes, Object.keys(homes), 'homes');
|
|
39
|
+
for (const key of Object.keys(homes)) if (Object.hasOwn(supplied, key)) {
|
|
40
|
+
if (typeof supplied[key] !== 'string') invalid(`homes.${key} must be a string`);
|
|
41
|
+
homes[key] = supplied[key];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return homes;
|
|
45
|
+
}
|
|
46
|
+
function params(value, context, notepad = false) {
|
|
47
|
+
const fields = notepad ? ['harness', 'id', 'key', 'value', 'profile', 'homes'] : mutationFields;
|
|
48
|
+
value = struct(value, fields, notepad ? 'notepad mutation' : 'job mutation');
|
|
49
|
+
if (Object.hasOwn(value, 'harness') && typeof value.harness !== 'string') invalid('harness must be a string');
|
|
50
|
+
if (notepad && Object.hasOwn(value, 'id') && typeof value.id !== 'string') invalid('id must be a string');
|
|
51
|
+
strings(value, notepad ? ['key', 'value', 'profile'] : ['id', 'name', 'session_target', 'profile', 'workdir', 'model', 'provider'], '');
|
|
52
|
+
const result = { ...value, harness: value.harness ?? '', id: value.id ?? (notepad ? '' : null) };
|
|
53
|
+
if (!notepad) {
|
|
54
|
+
for (const [name, fields, strict] of [['schedule', ['kind', 'minutes', 'expr', 'run_at'], true], ['payload', ['kind', 'text'], false], ['deliver', ['target', 'chat_id'], true]]) {
|
|
55
|
+
if (value[name] == null) continue;
|
|
56
|
+
const item = struct(value[name], fields, name, strict);
|
|
57
|
+
if (name !== 'deliver' && Object.hasOwn(item, 'kind') && typeof item.kind !== 'string') invalid(`${name}.kind must be a string`);
|
|
58
|
+
strings(item, name === 'schedule' ? ['expr', 'run_at'] : name === 'payload' ? ['text'] : fields, `${name}.`);
|
|
59
|
+
if (name === 'schedule' && item.minutes != null && typeof item.minutes !== 'number') invalid('schedule.minutes must be a number');
|
|
60
|
+
result[name] = { ...item, ...(name === 'deliver' ? {} : { kind: item.kind ?? '' }) };
|
|
61
|
+
}
|
|
62
|
+
if (value.skills != null && (!Array.isArray(value.skills) || value.skills.some(skill => typeof skill !== 'string'))) invalid('skills must be an array of strings');
|
|
63
|
+
if (value.repeat != null) {
|
|
64
|
+
const count = nativeInteger(value, 'repeat') ?? (nativeNumberKind(value, 'repeat') == null && Number.isInteger(value.repeat) && value.repeat >= 0 ? BigInt(value.repeat) : null);
|
|
65
|
+
if (count == null || count > 0xffffffffn) invalid('repeat must be an unsigned 32-bit integer');
|
|
66
|
+
result.repeat = Number(count);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
result.homes = selectedHomes(value, context);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function requireJobHarness(harness, method) {
|
|
73
|
+
if (!JOB_HARNESSES.includes(harness)) unsupported(`\`${harness}\` has no scheduled jobs; \`${method}\` is supported for: ${JOB_HARNESSES.join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
function harnessProgram(harness, context) {
|
|
76
|
+
const override = nativeTrim(context.env[harness === 'hermes' ? 'SUPERCODE_HERMES_BIN' : 'SUPERCODE_OPENCLAW_BIN'] ?? '');
|
|
77
|
+
if (override) return override;
|
|
78
|
+
const program = supportRegistryReport(context).harnesses.find(row => row.id === harness)?.runtime?.default_launch?.program;
|
|
79
|
+
if (program == null) unsupported(`the registry has no launch for \`${harness}\`, so its CLI cannot be located`);
|
|
80
|
+
return program.replace(/-acp$/u, '');
|
|
81
|
+
}
|
|
82
|
+
const hermesHome = mutation => mutation.profile == null ? parent(mutation.homes.hermes) ?? '.' : join(join(parent(mutation.homes.hermes) ?? '.', 'profiles'), mutation.profile);
|
|
83
|
+
function command(harness, mutation, context) {
|
|
84
|
+
const program = harnessProgram(harness, context), env = { ...context.env }, args = [], rendered = [];
|
|
85
|
+
if (harness === 'hermes') env.HERMES_HOME = hermesHome(mutation);
|
|
86
|
+
else { env.OPENCLAW_STATE_DIR = mutation.homes.openclaw; env.OPENCLAW_CONFIG_PATH = join(mutation.homes.openclaw, 'openclaw.json'); }
|
|
87
|
+
const add = (...values) => { args.push(...values); rendered.push(...values.map(quote)); };
|
|
88
|
+
const secret = value => { args.push(value); rendered.push('<redacted>'); };
|
|
89
|
+
return { add, secret, request: () => ({ program, args, env, owner: 'job', ran: [quote(program), ...rendered].join(' ') }) };
|
|
90
|
+
}
|
|
91
|
+
function gateway(homes, context) {
|
|
92
|
+
const descriptor = supportRegistryReport(context).harnesses.find(row => row.id === 'openclaw');
|
|
93
|
+
if (!descriptor) unsupported('the registry has no openclaw descriptor');
|
|
94
|
+
const connect = descriptor.runtime?.connect_launch;
|
|
95
|
+
if (!connect) unsupported('openclaw has no registered connect-mode launch, so its gateway cannot be located');
|
|
96
|
+
let path = join(homes.openclaw, 'openclaw.json');
|
|
97
|
+
if (path.startsWith('~/')) path = join('/', path.slice(2));
|
|
98
|
+
const actual = !path.length || isAbsolute(path) || process.platform === 'win32' && /^[A-Za-z]:/u.test(path) ? path : join(context.cwd, path);
|
|
99
|
+
let text, config = {};
|
|
100
|
+
try { text = utf8.decode(readFileSync(actual)); } catch { /* Native read failures use the declared default endpoint. */ }
|
|
101
|
+
if (text !== undefined) try { config = parseNativeJson(text); }
|
|
102
|
+
catch { unsupported(`openclaw's gateway endpoint could not be resolved from its own config: connect-mode config ${path} is not valid JSON`); }
|
|
103
|
+
const pointer = name => {
|
|
104
|
+
let value = config, owner, key;
|
|
105
|
+
for (const part of name?.slice(1).split('/') ?? []) { owner = value; key = part.replaceAll('~1', '/').replaceAll('~0', '~'); value = value != null && typeof value === 'object' ? value[key] : undefined; }
|
|
106
|
+
return { value, owner, key };
|
|
107
|
+
};
|
|
108
|
+
const explicit = pointer(connect.address_pointer).value, port = pointer(connect.port_pointer);
|
|
109
|
+
const integer = nativeInteger(port.owner, port.key), scheme = connect.default_address?.split('://')[0] ?? 'ws';
|
|
110
|
+
let address = typeof explicit === 'string' && nativeTrim(explicit) ? nativeTrim(explicit) : integer != null ? `${scheme}://127.0.0.1:${integer}` : connect.default_address;
|
|
111
|
+
if (address == null) unsupported(`openclaw's gateway endpoint could not be resolved from its own config: connect-mode address pointer \`${connect.address_pointer}\` does not name a non-empty string in ${path}`);
|
|
112
|
+
address = address.replace(/\/+$/u, ''); if (!address.includes('://')) address = `${scheme}://${address}`;
|
|
113
|
+
const supplied = pointer(connect.auth_pointer).value;
|
|
114
|
+
const token = typeof supplied === 'string' && nativeTrim(supplied) ? nativeTrim(supplied) : null;
|
|
115
|
+
if (connect.auth_pointer != null && token == null && connect.default_address == null) unsupported(`openclaw's gateway endpoint could not be resolved from its own config: connect-mode auth pointer \`${connect.auth_pointer}\` does not name a non-empty string in ${path}`);
|
|
116
|
+
return { address, token };
|
|
117
|
+
}
|
|
118
|
+
function schedule(schedule, typed = false) {
|
|
119
|
+
const field = { interval: 'minutes', cron: 'expr', once: 'run_at' }[schedule.kind];
|
|
120
|
+
if (!field) invalid(`unknown schedule kind \`${schedule.kind}\`; use interval, cron, or once`);
|
|
121
|
+
if (schedule[field] == null) invalid(`a${schedule.kind === 'interval' ? 'n' : ''} ${schedule.kind} schedule needs \`${field}\``);
|
|
122
|
+
if (typed) { const value = { kind: schedule.kind, [field]: schedule[field] }; if (field === 'minutes') setNativeFloat(value, field, schedule[field]); return value; }
|
|
123
|
+
return field === 'minutes' ? `every ${jobFloat(schedule.minutes, true)}m` : schedule[field];
|
|
124
|
+
}
|
|
125
|
+
const payloadKind = payload => nativeTrim(payload.kind) || 'prompt';
|
|
126
|
+
function payloadText(payload) { if (payload.text == null || !nativeTrim(payload.text)) invalid(`a \`${payloadKind(payload)}\` payload needs its text`); return payload.text; }
|
|
127
|
+
function deliverWord(deliver) {
|
|
128
|
+
if (deliver.target == null) return null;
|
|
129
|
+
const target = nativeTrim(deliver.target), chat = deliver.chat_id;
|
|
130
|
+
return chat != null && !target.includes(':') && nativeTrim(chat) ? `${target}:${nativeTrim(chat)}` : target;
|
|
131
|
+
}
|
|
132
|
+
function hermesCommand(verb, mutation, context) {
|
|
133
|
+
if (mutation.deliver != null && !nativeTrim(mutation.deliver.target ?? '') && mutation.deliver.chat_id != null) invalid('a delivery needs a target; a chat id alone names no destination, so supercode refuses rather than dropping it');
|
|
134
|
+
if (mutation.session_target != null) unsupported('hermes cron fires always open their own `platform=cron` session; hermes has no session-target verb, so Volter Harness refuses rather than dropping the field');
|
|
135
|
+
const c = command('hermes', mutation, context), add = c.add;
|
|
136
|
+
add('cron', { create: 'create', update: 'edit', pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' }[verb]);
|
|
137
|
+
if (verb !== 'create') add(mutation.id);
|
|
138
|
+
const definitions = () => {
|
|
139
|
+
if (mutation.skills != null) { if (!mutation.skills.length && verb === 'update') add('--clear-skills'); for (const skill of mutation.skills) add('--skill', skill); }
|
|
140
|
+
for (const field of ['workdir', 'model', 'provider', 'repeat']) if (mutation[field] != null) add(`--${field}`, String(mutation[field]));
|
|
141
|
+
};
|
|
142
|
+
const payload = () => {
|
|
143
|
+
if (mutation.payload == null) return;
|
|
144
|
+
const kind = payloadKind(mutation.payload);
|
|
145
|
+
if (!['prompt', 'script'].includes(kind)) unsupported(`hermes cron carries a \`prompt\` or a \`--script\` payload; it has no verb for a \`${kind}\` payload`);
|
|
146
|
+
const text = payloadText(mutation.payload); kind === 'script' ? add('--script', text) : verb === 'update' ? add('--prompt', text) : add(text);
|
|
147
|
+
};
|
|
148
|
+
if (verb === 'create' || verb === 'update') {
|
|
149
|
+
if (verb === 'update' && mutation.schedule != null) add('--schedule', schedule(mutation.schedule));
|
|
150
|
+
if (mutation.name != null) add('--name', mutation.name);
|
|
151
|
+
if (mutation.deliver != null) { const word = deliverWord(mutation.deliver); if (word != null) add('--deliver', word); }
|
|
152
|
+
if (verb === 'create' && mutation.payload != null && payloadKind(mutation.payload) === 'script') add('--script', payloadText(mutation.payload));
|
|
153
|
+
definitions();
|
|
154
|
+
if (verb === 'create') add(schedule(mutation.schedule));
|
|
155
|
+
if (!(verb === 'create' && mutation.payload != null && payloadKind(mutation.payload) === 'script')) payload();
|
|
156
|
+
}
|
|
157
|
+
return c.request();
|
|
158
|
+
}
|
|
159
|
+
function openclawCommand(verb, mutation, context) {
|
|
160
|
+
const connection = gateway(mutation.homes, context), c = command('openclaw', mutation, context), add = c.add;
|
|
161
|
+
add('cron', { create: 'add', update: 'edit', pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' }[verb]);
|
|
162
|
+
if (verb !== 'create') add(mutation.id);
|
|
163
|
+
add('--url', connection.address); if (connection.token != null) { add('--token'); c.secret(connection.token); }
|
|
164
|
+
if (verb === 'create' || verb === 'update') {
|
|
165
|
+
if (mutation.name != null) add('--name', mutation.name);
|
|
166
|
+
if (mutation.schedule != null) { const value = schedule(mutation.schedule); add({ interval: '--every', cron: '--cron', once: '--at' }[mutation.schedule.kind], mutation.schedule.kind === 'interval' ? value.slice(6) : value); }
|
|
167
|
+
if (mutation.payload != null) {
|
|
168
|
+
const kind = payloadKind(mutation.payload), flag = { prompt: '--message', system_event: '--system-event', command: '--command' }[kind];
|
|
169
|
+
if (!flag) unsupported(`openclaw cron carries \`message\`, \`system-event\` or \`command\` payloads; it has no verb for a \`${kind}\` payload`);
|
|
170
|
+
add(flag, payloadText(mutation.payload));
|
|
171
|
+
}
|
|
172
|
+
if (mutation.session_target != null) add('--session', mutation.session_target);
|
|
173
|
+
if (mutation.profile != null) add('--agent', mutation.profile);
|
|
174
|
+
if (mutation.deliver != null) {
|
|
175
|
+
const target = mutation.deliver.target == null ? null : nativeTrim(mutation.deliver.target), chat = mutation.deliver.chat_id;
|
|
176
|
+
if (target == null) { if (chat != null) add('--to', chat); }
|
|
177
|
+
else if (target === 'announce') { add('--announce'); if (chat != null) add('--to', chat); }
|
|
178
|
+
else if (target === 'webhook') { if (chat == null) invalid('an openclaw `webhook` delivery needs the URL in `chat_id`'); add('--webhook', chat); }
|
|
179
|
+
else if (target === 'none') add('--no-deliver');
|
|
180
|
+
else unsupported(`openclaw delivers \`announce\`, \`webhook\`, or \`none\`; it has no \`${target}\` delivery mode`);
|
|
181
|
+
}
|
|
182
|
+
if (verb === 'create') add('--json');
|
|
183
|
+
} else if (['profile', 'session_target', 'deliver', 'name', 'schedule', 'payload'].some(field => mutation[field] != null)) invalid(`\`jobs.${verb}\` changes no fields; pass definition fields to \`jobs.update\``);
|
|
184
|
+
return c.request();
|
|
185
|
+
}
|
|
186
|
+
function orchestratorArgs(verb, mutation) {
|
|
187
|
+
const args = {}; if (nativeTrim(mutation.id ?? '')) args.id = nativeTrim(mutation.id);
|
|
188
|
+
if (verb === 'create' || verb === 'update') {
|
|
189
|
+
if (mutation.session_target != null) unsupported('an orchestrator cron fire opens its own binding on the job\'s origin surface (`docs/ORCHESTRATOR-IR.md` §4.3); the model has no session-target field, so Volter Harness refuses rather than dropping it');
|
|
190
|
+
if (mutation.name != null) args.name = mutation.name;
|
|
191
|
+
if (mutation.schedule != null) args.schedule = schedule(mutation.schedule, true);
|
|
192
|
+
if (mutation.payload != null) { const kind = payloadKind(mutation.payload); if (kind !== 'prompt') unsupported(`an orchestrator job carries a \`prompt\` — the fire opens a worker session and sends it (§4.3); there is no \`${kind}\` payload, so supercode refuses rather than inventing one`); args.prompt = payloadText(mutation.payload); }
|
|
193
|
+
if (mutation.deliver != null) { const word = deliverWord(mutation.deliver); if (word != null) args.deliver = word; }
|
|
194
|
+
} else if (['name', 'schedule', 'payload', 'deliver', 'session_target'].some(field => mutation[field] != null)) invalid(`\`jobs.${verb}\` changes no fields; pass definition fields to \`jobs.update\``);
|
|
195
|
+
return args;
|
|
196
|
+
}
|
|
197
|
+
async function rows(mutation, context) { return (await listJobs({ harness: mutation.harness, homes: mutation.homes }, context)).jobs; }
|
|
198
|
+
async function ids(mutation, context) { try { return new Set((await rows(mutation, context)).map(job => job.id)); } catch { return new Set(); } }
|
|
199
|
+
async function createdId(mutation, before, stdout, ran, context) {
|
|
200
|
+
const after = await ids(mutation, context), fresh = [...after].filter(id => !before.has(id)).sort(compare);
|
|
201
|
+
if (fresh.length === 1) return fresh[0];
|
|
202
|
+
const named = fresh.find(id => stdout.includes(id)); if (named !== undefined) return named;
|
|
203
|
+
let value; try { value = parseNativeJson(nativeTrim(stdout)); } catch { /* no JSON id hint */ }
|
|
204
|
+
for (const id of [value?.id, value?.job?.id, value?.jobId, value?.job_id, value?.result?.id]) if (typeof id === 'string') { if (after.has(id)) return id; break; }
|
|
205
|
+
const said = nativeTrim(stdout);
|
|
206
|
+
failed(`\`${ran}\` reported success but ${mutation.harness} gained ${fresh.length} job(s), so the new job cannot be identified${said ? `; ${mutation.harness} said: ${said}` : ''}`);
|
|
207
|
+
}
|
|
208
|
+
export async function mutateJob(verb, value, { env = process.env, cwd = process.cwd(), signal } = {}) {
|
|
209
|
+
const context = { env, cwd, signal }, mutation = params(value, context); requireJobHarness(mutation.harness, `jobs.${verb}`);
|
|
210
|
+
if (!verbs.includes(verb)) invalid(`unknown job mutation: ${verb}`);
|
|
211
|
+
if (!CONTROLLED_JOB_HARNESSES.includes(mutation.harness)) unsupported('claude-code scheduled jobs are session-scoped runtime state: they are created by the model inside a session (`CronCreate`) and restored on resume. Claude Code publishes no harness verb a client can call, so Volter Harness refuses rather than inventing one');
|
|
212
|
+
if (verb !== 'create' && !nativeTrim(mutation.id ?? '')) invalid(`\`jobs.${verb}\` needs the job id to act on`);
|
|
213
|
+
if (verb === 'create' && mutation.schedule == null) invalid('`jobs.create` needs a schedule (interval, cron, or once)');
|
|
214
|
+
if (mutation.harness !== 'hermes') { const field = ['skills', 'workdir', 'model', 'provider', 'repeat'].find(field => mutation[field] != null); if (field) unsupported(`${mutation.harness} has no job verb that sets \`${field}\`, so Volter Harness refuses rather than dropping it`); }
|
|
215
|
+
let ran, id = mutation.id;
|
|
216
|
+
if (mutation.harness === 'orchestrator') {
|
|
217
|
+
const args = orchestratorArgs(verb, mutation), profile = nativeTrim(mutation.profile ?? '') || 'default';
|
|
218
|
+
const answer = await callProfileOrchestrator(mutation.homes.orchestrator, `jobs.${verb}`, args, { ...context, owner: 'job' }, profile); ran = `${answer.ran} [${answer.door}]`;
|
|
219
|
+
id = typeof answer.result?.job_id === 'string' ? answer.result.job_id : mutation.id;
|
|
220
|
+
if (id == null) failed(`\`${ran}\` succeeded but named no job id`);
|
|
221
|
+
} else {
|
|
222
|
+
const request = mutation.harness === 'hermes' ? hermesCommand(verb, mutation, context) : openclawCommand(verb, mutation, context);
|
|
223
|
+
const before = verb === 'create' ? await ids(mutation, context) : null, output = await runProfileCommand(request, context); requireProfileCommandSuccess(request, output); ran = request.ran;
|
|
224
|
+
if (mutation.harness === 'hermes') { const said = output.stdout.split(/\r?\n/u).map(nativeTrim).find(line => line.startsWith('Failed to ') || line.startsWith('Job not found')); if (said !== undefined) failed(`\`${ran}\` exited 0 but hermes said: ${said}`); }
|
|
225
|
+
if (verb === 'create') id = await createdId(mutation, before, output.stdout, ran, context);
|
|
226
|
+
}
|
|
227
|
+
let job; try { job = (await rows(mutation, context)).find(job => job.id === id); } catch (error) { failed(`\`${ran}\` succeeded but the job store could not be re-read: ${error.message}`); }
|
|
228
|
+
if (verb === 'delete') {
|
|
229
|
+
if (job) failed(`\`${ran}\` reported success but \`${id}\` is still in ${mutation.harness === 'orchestrator' ? 'the orchestrator\'s' : `${mutation.harness}'s`} job store`);
|
|
230
|
+
return { harness: mutation.harness, verb, ran, id, deleted: true };
|
|
231
|
+
}
|
|
232
|
+
if (!job) failed(`\`${ran}\` reported success but ${mutation.harness === 'orchestrator' ? 'the orchestrator' : `\`${mutation.harness}\``} has no job \`${id}\` afterwards`);
|
|
233
|
+
return { harness: mutation.harness, verb, ran, id, job };
|
|
234
|
+
}
|
|
235
|
+
export async function mutateJobNotepad(verb, value, { env = process.env, cwd = process.cwd(), signal } = {}) {
|
|
236
|
+
if (!['set', 'delete'].includes(verb)) invalid(`unknown notepad mutation: ${verb}`);
|
|
237
|
+
const context = { env, cwd, signal }, mutation = params(value, context, true); requireJobHarness(mutation.harness, 'jobs.notepad');
|
|
238
|
+
if (!nativeTrim(mutation.id)) invalid('a notepad call needs the job id');
|
|
239
|
+
if (mutation.harness !== 'hermes') unsupported(`\`${mutation.harness}\` keeps no job notepad; the notepad is supported for: hermes`);
|
|
240
|
+
if (verb === 'set' ? mutation.key == null || mutation.value == null : mutation.key == null) invalid(verb === 'set' ? '`notepad set` needs a key and a value' : '`notepad delete` needs a key');
|
|
241
|
+
const job = (await rows(mutation, context)).find(job => job.id === mutation.id);
|
|
242
|
+
if (!job) invalid(`${mutation.harness} has no job \`${mutation.id}\``);
|
|
243
|
+
if (job.profile !== (mutation.profile ?? null)) invalid(`job \`${mutation.id}\` belongs to ${job.profile == null ? 'the root home' : `profile \`${job.profile}\``}; name that profile with \`profile\``);
|
|
244
|
+
const c = command('hermes', mutation, context); c.add('cron', 'notepad', mutation.id, verb, '--', mutation.key); if (verb === 'set') c.add(mutation.value);
|
|
245
|
+
const request = c.request(), output = await runProfileCommand(request, context); requireProfileCommandSuccess(request, output);
|
|
246
|
+
const read = readJobNotepad(mutation, context);
|
|
247
|
+
if (verb === 'set' && !(read.entries.length === 1 && read.entries[0].value === mutation.value)) failed(`\`${request.ran}\` exited 0 but the store does not hold \`${mutation.key}\` = the value sent afterwards`);
|
|
248
|
+
if (verb === 'delete' && read.entries.length) failed(`\`${request.ran}\` exited 0 but the store still holds \`${mutation.key}\``);
|
|
249
|
+
return { ...read, ran: request.ran };
|
|
250
|
+
}
|