@bli-cockpit/cli 0.2.211 → 0.2.213
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/dist/commands/agent-door.js +5 -3
- package/dist/commands/local-args-tower-manager.js +371 -0
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-command-shapes-manager.js +9 -0
- package/dist/commands/local-help-commands-tower-ops.js +41 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +8 -12
- package/dist/commands/manager-render.js +168 -0
- package/dist/commands/manager-requests.js +100 -0
- package/dist/commands/manager.js +211 -0
- package/dist/commands/public-root.js +1 -1
- package/package.json +4 -4
|
@@ -52,6 +52,7 @@ export async function askAgentDoor(door, options) {
|
|
|
52
52
|
httpStatus: status,
|
|
53
53
|
reason: doorReason(body) ?? `http_${status}`,
|
|
54
54
|
detail: doorMessage(body) ?? `Tower answered ${status} and said nothing about why.`,
|
|
55
|
+
body,
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
return { ok: true, httpStatus: result.response.status, body };
|
|
@@ -76,10 +77,11 @@ export function emitAgentDoor(door, body, exitCode = 0) {
|
|
|
76
77
|
writeLine(door.io.stdout, JSON.stringify(body));
|
|
77
78
|
return exitCode;
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
/** `extra` is for a command that has more to say than the reason (`refusal`); a caller that passes none prints exactly what it always did. */
|
|
81
|
+
export function failAgentDoor(door, tag, reason, detail, extra = {}) {
|
|
82
|
+
writeLine(door.io.stderr, `${tag} refused ${JSON.stringify({ reason, ...extra })}`);
|
|
81
83
|
if (door.json) {
|
|
82
|
-
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
84
|
+
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail, ...extra }));
|
|
83
85
|
}
|
|
84
86
|
else {
|
|
85
87
|
writeLine(door.io.stderr, detail);
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit manager` argument parsing (BLI-5006): client actions and timeline items from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* cockpit manager clients
|
|
5
|
+
* cockpit manager client <client>
|
|
6
|
+
* cockpit manager run <run-id>
|
|
7
|
+
* cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name> | --no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]
|
|
8
|
+
* cockpit manager queue <client>
|
|
9
|
+
* cockpit manager show <id> [--timeline]
|
|
10
|
+
* cockpit manager add <client> --title <t> [--owners "A, B"] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
|
|
11
|
+
* cockpit manager add <client> --timeline --kind <kind> --title <t> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
|
|
12
|
+
* cockpit manager edit <id> --version <v> [--timeline] [--title] [--detail] [--due <date>|none] [--owners "A, B"|none]
|
|
13
|
+
* (timeline: --kind, --date <date>|none, --owner <one name>|none)
|
|
14
|
+
* cockpit manager done|reopen <action> --version <v>
|
|
15
|
+
* cockpit manager link <action> --version <v> --meeting <timeline-id> | --none
|
|
16
|
+
* cockpit manager approve <id> --version <v> [--timeline] [--restore] (--restore brings a dismissed item back)
|
|
17
|
+
* cockpit manager dismiss|archive|unarchive <id> --version <v> [--timeline]
|
|
18
|
+
* cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline]
|
|
19
|
+
*
|
|
20
|
+
* Every verb is one `/api/manager` door on this machine's device token. A bare `cockpit manager`,
|
|
21
|
+
* `help` and `--help` are the help, never a list and never another command.
|
|
22
|
+
*/
|
|
23
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
24
|
+
export const MANAGER_VERBS = [
|
|
25
|
+
"clients", "client", "run", "list", "queue", "show", "add", "edit", "done", "reopen", "link",
|
|
26
|
+
"approve", "dismiss", "proposal", "archive", "unarchive",
|
|
27
|
+
];
|
|
28
|
+
export const MANAGER_ACTION_STATUSES = ["open", "done", "dropped", "closed", "all"];
|
|
29
|
+
export const MANAGER_TIMELINE_KINDS = ["meeting", "document", "milestone", "internal"];
|
|
30
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
+
const VERSION = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
32
|
+
const ONE_VERB = `manager takes one verb: ${MANAGER_VERBS.join(", ")}. Example: \`cockpit manager clients\` or \`cockpit manager list <client>\`.`;
|
|
33
|
+
const OWN_FLAGS = [
|
|
34
|
+
"--timeline", "--needs-review", "--status", "--owner", "--owners", "--no-owner", "--kind", "--search", "--archived", "--review",
|
|
35
|
+
"--title", "--detail", "--due", "--date", "--meeting", "--none", "--version", "--request-id", "--restore", "--overdue", "--source-url", "--source-note",
|
|
36
|
+
];
|
|
37
|
+
const COMMON_FLAGS = ["--home", "--dashboard-url", "--json", "--help"];
|
|
38
|
+
const VALUE_FLAGS = [
|
|
39
|
+
"--status", "--owner", "--owners", "--kind", "--search", "--review", "--title", "--detail", "--due", "--date", "--meeting",
|
|
40
|
+
"--version", "--request-id", "--source-url", "--source-note", "--home", "--dashboard-url",
|
|
41
|
+
];
|
|
42
|
+
/** Which flags each verb owns, so a flag on the wrong verb is refused by name rather than ignored. */
|
|
43
|
+
export const FLAGS_BY_VERB = {
|
|
44
|
+
clients: [],
|
|
45
|
+
client: [],
|
|
46
|
+
run: [],
|
|
47
|
+
list: ["--timeline", "--needs-review", "--status", "--owner", "--no-owner", "--overdue", "--kind", "--search", "--archived", "--review"],
|
|
48
|
+
queue: [],
|
|
49
|
+
show: ["--timeline"],
|
|
50
|
+
add: ["--timeline", "--title", "--detail", "--owners", "--owner", "--due", "--meeting", "--kind", "--date", "--source-url", "--source-note", "--request-id"],
|
|
51
|
+
edit: ["--timeline", "--version", "--title", "--detail", "--owners", "--owner", "--due", "--kind", "--date"],
|
|
52
|
+
done: ["--version"],
|
|
53
|
+
reopen: ["--version"],
|
|
54
|
+
link: ["--version", "--meeting", "--none"],
|
|
55
|
+
approve: ["--timeline", "--version", "--restore"],
|
|
56
|
+
dismiss: ["--timeline", "--version"],
|
|
57
|
+
proposal: ["--timeline", "--version"],
|
|
58
|
+
archive: ["--timeline", "--version"],
|
|
59
|
+
unarchive: ["--timeline", "--version"],
|
|
60
|
+
};
|
|
61
|
+
/** Positional words each verb takes, named for the refusal. */
|
|
62
|
+
const POSITIONALS = {
|
|
63
|
+
clients: [],
|
|
64
|
+
client: ["client"],
|
|
65
|
+
run: ["run id"],
|
|
66
|
+
list: ["client"],
|
|
67
|
+
queue: ["client"],
|
|
68
|
+
show: ["id"],
|
|
69
|
+
add: ["client"],
|
|
70
|
+
edit: ["id"],
|
|
71
|
+
done: ["action id"],
|
|
72
|
+
reopen: ["action id"],
|
|
73
|
+
link: ["action id"],
|
|
74
|
+
approve: ["id"],
|
|
75
|
+
dismiss: ["id"],
|
|
76
|
+
proposal: ["id", "proposal id", "accept, dismiss or add-new"],
|
|
77
|
+
archive: ["id"],
|
|
78
|
+
unarchive: ["id"],
|
|
79
|
+
};
|
|
80
|
+
/** A value flag given twice would silently keep only the last; say so instead. */
|
|
81
|
+
function refuseRepeatedFlags(args) {
|
|
82
|
+
for (const flag of VALUE_FLAGS) {
|
|
83
|
+
const times = args.filter((arg) => arg === flag || arg.startsWith(`${flag}=`)).length;
|
|
84
|
+
if (times > 1) {
|
|
85
|
+
throw new Error(flag === "--owners"
|
|
86
|
+
? '--owners was given more than once. Put everyone in one list: --owners "Pip Moss, Rowan Ash".'
|
|
87
|
+
: `${flag} was given more than once. Give it once.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function parseManagerArgs(args) {
|
|
92
|
+
refuseRepeatedFlags(args);
|
|
93
|
+
const values = parseNamedArgs(args, { allowedFlags: [...OWN_FLAGS, ...COMMON_FLAGS], valueFlags: VALUE_FLAGS });
|
|
94
|
+
const base = {
|
|
95
|
+
kind: "manager",
|
|
96
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
97
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
98
|
+
json: values.booleans.has("--json"),
|
|
99
|
+
};
|
|
100
|
+
const first = values.positionals[0];
|
|
101
|
+
if (values.booleans.has("--help") || first === undefined || first === "help" || first === "-h") {
|
|
102
|
+
return { ...base, action: "clients", help: true, timeline: false };
|
|
103
|
+
}
|
|
104
|
+
if (!MANAGER_VERBS.includes(first))
|
|
105
|
+
throw new Error(ONE_VERB);
|
|
106
|
+
const verb = first;
|
|
107
|
+
const owned = FLAGS_BY_VERB[verb];
|
|
108
|
+
const stray = [...values.flags.keys(), ...values.booleans].find((flag) => OWN_FLAGS.includes(flag) && !owned.includes(flag));
|
|
109
|
+
if (stray)
|
|
110
|
+
throw new Error(`${stray} does not belong to \`cockpit manager ${verb}\`.`);
|
|
111
|
+
const rest = values.positionals.slice(1);
|
|
112
|
+
const wanted = POSITIONALS[verb];
|
|
113
|
+
if (rest.length !== wanted.length) {
|
|
114
|
+
throw new Error(wanted.length === 0
|
|
115
|
+
? `\`cockpit manager ${verb}\` takes no positional arguments.`
|
|
116
|
+
: `\`cockpit manager ${verb}\` takes exactly: ${wanted.map((word) => `<${word}>`).join(" ")}${verb === "list" || verb === "queue" || verb === "add" || verb === "client" ? " (quote a client's name that has spaces)" : ""}.`);
|
|
117
|
+
}
|
|
118
|
+
const timeline = values.booleans.has("--timeline");
|
|
119
|
+
const shape = { ...base, action: verb, timeline };
|
|
120
|
+
const flag = (name) => values.flags.get(name);
|
|
121
|
+
if (verb === "list" || verb === "queue" || verb === "add" || verb === "client")
|
|
122
|
+
shape.client = rest[0];
|
|
123
|
+
else if (verb === "run")
|
|
124
|
+
shape.runId = readId(rest[0], "A run");
|
|
125
|
+
else if (verb !== "clients")
|
|
126
|
+
shape.id = readId(rest[0], verb === "done" || verb === "reopen" || verb === "link" ? "An action" : "An item");
|
|
127
|
+
if (verb === "proposal") {
|
|
128
|
+
shape.proposalId = rest[1];
|
|
129
|
+
shape.decision = readDecision(rest[2]);
|
|
130
|
+
}
|
|
131
|
+
const needsVersion = !["clients", "client", "run", "list", "queue", "show", "add"].includes(verb);
|
|
132
|
+
if (needsVersion)
|
|
133
|
+
shape.version = readVersion(flag("--version"), verb);
|
|
134
|
+
if (verb === "list")
|
|
135
|
+
readListFilters(values, shape);
|
|
136
|
+
if (verb === "add")
|
|
137
|
+
readAddFields(values, shape);
|
|
138
|
+
if (verb === "edit")
|
|
139
|
+
readEditFields(values, shape);
|
|
140
|
+
if (verb === "link")
|
|
141
|
+
readLink(values, shape);
|
|
142
|
+
if (verb === "approve" && values.booleans.has("--restore"))
|
|
143
|
+
shape.restore = true;
|
|
144
|
+
return shape;
|
|
145
|
+
}
|
|
146
|
+
function readId(value, what) {
|
|
147
|
+
if (!ID.test(value))
|
|
148
|
+
throw new Error(`${what} id looks like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10 (\`cockpit manager list <client>\` prints them).`);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
function readVersion(raw, verb) {
|
|
152
|
+
if (raw === undefined) {
|
|
153
|
+
throw new Error(`\`cockpit manager ${verb}\` needs --version <version>: the item's version exactly as \`cockpit manager show <id>\` prints it. It keeps you from writing over a change someone else made.`);
|
|
154
|
+
}
|
|
155
|
+
if (!VERSION.test(raw))
|
|
156
|
+
throw new Error("--version is the item's version exactly as `cockpit manager show <id>` prints it, like 2026-10-09T17:03:11.123456+00:00.");
|
|
157
|
+
return raw;
|
|
158
|
+
}
|
|
159
|
+
function readDecision(word) {
|
|
160
|
+
if (word === "accept" || word === "dismiss")
|
|
161
|
+
return word;
|
|
162
|
+
if (word === "add-new" || word === "add_as_new")
|
|
163
|
+
return "add_as_new";
|
|
164
|
+
throw new Error("The last word of `cockpit manager proposal` is accept, dismiss or add-new.");
|
|
165
|
+
}
|
|
166
|
+
/** One calendar date, `YYYY-MM-DD`, that exists (`2026-02-30` does not). */
|
|
167
|
+
export function isCalendarDate(value) {
|
|
168
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
169
|
+
if (!match)
|
|
170
|
+
return false;
|
|
171
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
172
|
+
const date = new Date(Date.UTC(2000, 0, 1));
|
|
173
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
174
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
175
|
+
}
|
|
176
|
+
function readDate(raw, flag) {
|
|
177
|
+
if (!isCalendarDate(raw))
|
|
178
|
+
throw new Error(`${flag} wants a real date written YYYY-MM-DD, like 2026-10-31.`);
|
|
179
|
+
return raw;
|
|
180
|
+
}
|
|
181
|
+
/** "A, B" is two people: split on commas, trim, drop blanks. */
|
|
182
|
+
export function splitOwners(raw) {
|
|
183
|
+
return raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
184
|
+
}
|
|
185
|
+
function readKind(raw, flag) {
|
|
186
|
+
if (!MANAGER_TIMELINE_KINDS.includes(raw))
|
|
187
|
+
throw new Error(`${flag} wants one of ${MANAGER_TIMELINE_KINDS.join(", ")}.`);
|
|
188
|
+
return raw;
|
|
189
|
+
}
|
|
190
|
+
function readListFilters(values, shape) {
|
|
191
|
+
const status = values.flags.get("--status");
|
|
192
|
+
const owner = values.flags.get("--owner");
|
|
193
|
+
const noOwner = values.booleans.has("--no-owner");
|
|
194
|
+
const kind = values.flags.get("--kind");
|
|
195
|
+
const review = values.flags.get("--review");
|
|
196
|
+
const overdue = values.booleans.has("--overdue");
|
|
197
|
+
if (shape.timeline && (status || owner || noOwner || overdue))
|
|
198
|
+
throw new Error(`${status ? "--status" : owner ? "--owner" : noOwner ? "--no-owner" : "--overdue"} filters actions, and a timeline list does not take it. Drop --timeline, or filter the timeline with --kind.`);
|
|
199
|
+
if (!shape.timeline && kind)
|
|
200
|
+
throw new Error("--kind filters the timeline. Add --timeline.");
|
|
201
|
+
if (owner && noOwner)
|
|
202
|
+
throw new Error("--owner and --no-owner say opposite things. Give one.");
|
|
203
|
+
if (status && !MANAGER_ACTION_STATUSES.includes(status))
|
|
204
|
+
throw new Error(`--status wants one of ${MANAGER_ACTION_STATUSES.join(", ")}.`);
|
|
205
|
+
if (review !== undefined && review !== "dismissed" && review !== "all")
|
|
206
|
+
throw new Error("--review wants dismissed or all (dismissed items are hidden unless you ask).");
|
|
207
|
+
if (kind)
|
|
208
|
+
shape.kindFilter = readKind(kind, "--kind");
|
|
209
|
+
if (status)
|
|
210
|
+
shape.status = status;
|
|
211
|
+
if (owner)
|
|
212
|
+
shape.ownerFilter = owner.trim();
|
|
213
|
+
if (noOwner)
|
|
214
|
+
shape.noOwner = true;
|
|
215
|
+
if (overdue)
|
|
216
|
+
shape.overdue = true;
|
|
217
|
+
if (review)
|
|
218
|
+
shape.review = review === "dismissed" ? "rejected" : "all";
|
|
219
|
+
if (values.booleans.has("--archived"))
|
|
220
|
+
shape.archived = true;
|
|
221
|
+
const search = optionalNonEmpty(values.flags.get("--search"));
|
|
222
|
+
if (search)
|
|
223
|
+
shape.search = search;
|
|
224
|
+
if (values.booleans.has("--needs-review"))
|
|
225
|
+
shape.needsReview = true;
|
|
226
|
+
}
|
|
227
|
+
/** The flags only an action has, and the flags only a timeline item has, refused on the wrong one in plain words. */
|
|
228
|
+
function refuseWrongItemFlags(values, shape) {
|
|
229
|
+
const given = (name) => values.flags.has(name) || values.booleans.has(name);
|
|
230
|
+
if (shape.timeline) {
|
|
231
|
+
if (given("--owners"))
|
|
232
|
+
throw new Error("A timeline item has one owner: use --owner <name> (or --owner none). --owners is for an action.");
|
|
233
|
+
const action = ["--due", "--meeting"].find(given);
|
|
234
|
+
if (action)
|
|
235
|
+
throw new Error(`${action} belongs to an action; a timeline item has a --date. Drop --timeline for an action.`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
if (given("--owner"))
|
|
239
|
+
throw new Error("An action has several owners: use --owners \"A, B\" (or --owners none). --owner is for a timeline item.");
|
|
240
|
+
const item = ["--kind", "--date"].find(given);
|
|
241
|
+
if (item)
|
|
242
|
+
throw new Error(`${item} belongs to a timeline item. Add --timeline, or use --due for an action's date.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const isNone = (value) => value.trim().toLowerCase() === "none";
|
|
246
|
+
/** `--owners "A, B"` replaces the whole list; `none` is the only way to clear it; a list of blanks is neither. */
|
|
247
|
+
function readOwnersList(raw) {
|
|
248
|
+
if (isNone(raw))
|
|
249
|
+
return [];
|
|
250
|
+
// Commas separate people (the web form's rule), so a name can hold neither a comma nor the quotes around one.
|
|
251
|
+
if (raw.includes('"'))
|
|
252
|
+
throw new Error('--owners splits people on commas, so a name cannot hold a comma or a double quote. Write the names without them: --owners "Pip Moss, Rowan Ash".');
|
|
253
|
+
const owners = splitOwners(raw);
|
|
254
|
+
if (owners.length === 0)
|
|
255
|
+
throw new Error("--owners names nobody. Give one or more people separated by commas, or none to clear them.");
|
|
256
|
+
return owners;
|
|
257
|
+
}
|
|
258
|
+
function readOneOwner(raw) {
|
|
259
|
+
if (isNone(raw))
|
|
260
|
+
return null;
|
|
261
|
+
const name = raw.trim();
|
|
262
|
+
if (name === "")
|
|
263
|
+
throw new Error("--owner needs a name, or none to clear it.");
|
|
264
|
+
return name;
|
|
265
|
+
}
|
|
266
|
+
function readAddFields(values, shape) {
|
|
267
|
+
refuseWrongItemFlags(values, shape);
|
|
268
|
+
const title = optionalNonEmpty(values.flags.get("--title"));
|
|
269
|
+
if (!title)
|
|
270
|
+
throw new Error("`cockpit manager add` needs --title <text>.");
|
|
271
|
+
shape.title = title;
|
|
272
|
+
const detail = values.flags.get("--detail");
|
|
273
|
+
if (detail !== undefined)
|
|
274
|
+
shape.detail = detail;
|
|
275
|
+
const requestId = values.flags.get("--request-id");
|
|
276
|
+
if (requestId !== undefined)
|
|
277
|
+
shape.requestId = readId(requestId, "A request");
|
|
278
|
+
readSource(values, shape);
|
|
279
|
+
if (shape.timeline) {
|
|
280
|
+
const kind = values.flags.get("--kind");
|
|
281
|
+
if (!kind)
|
|
282
|
+
throw new Error(`\`cockpit manager add --timeline\` needs --kind ${MANAGER_TIMELINE_KINDS.join("|")}.`);
|
|
283
|
+
shape.itemKind = readKind(kind, "--kind");
|
|
284
|
+
const date = values.flags.get("--date");
|
|
285
|
+
if (date && !isNone(date))
|
|
286
|
+
shape.occurredAt = readDate(date, "--date");
|
|
287
|
+
const owner = values.flags.get("--owner");
|
|
288
|
+
const name = owner === undefined ? null : readOneOwner(owner);
|
|
289
|
+
if (name)
|
|
290
|
+
shape.ownerName = name;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const due = values.flags.get("--due");
|
|
294
|
+
if (due && !isNone(due))
|
|
295
|
+
shape.dueAt = readDate(due, "--due");
|
|
296
|
+
const meeting = values.flags.get("--meeting");
|
|
297
|
+
if (meeting)
|
|
298
|
+
shape.timelineItemId = readId(meeting, "A timeline item");
|
|
299
|
+
const owners = values.flags.get("--owners");
|
|
300
|
+
const list = owners === undefined ? [] : readOwnersList(owners);
|
|
301
|
+
if (list.length > 0)
|
|
302
|
+
shape.owners = list;
|
|
303
|
+
}
|
|
304
|
+
/** Where an item a person adds came from (the web form's Source field): a link, a note, or both. The server checks them again. */
|
|
305
|
+
function readSource(values, shape) {
|
|
306
|
+
const url = optionalNonEmpty(values.flags.get("--source-url"));
|
|
307
|
+
if (url !== undefined) {
|
|
308
|
+
if (!/^https?:\/\/\S+$/i.test(url.trim()))
|
|
309
|
+
throw new Error("--source-url wants a link starting with http:// or https://. Put anything else in --source-note.");
|
|
310
|
+
shape.sourceUrl = url.trim();
|
|
311
|
+
}
|
|
312
|
+
const note = optionalNonEmpty(values.flags.get("--source-note"));
|
|
313
|
+
if (note !== undefined) {
|
|
314
|
+
if (note.trim().length > 1000)
|
|
315
|
+
throw new Error("--source-note is at most 1,000 characters. A link can be longer: use --source-url.");
|
|
316
|
+
shape.sourceNote = note.trim();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function readEditFields(values, shape) {
|
|
320
|
+
refuseWrongItemFlags(values, shape);
|
|
321
|
+
let changes = 0;
|
|
322
|
+
const title = values.flags.get("--title");
|
|
323
|
+
if (title !== undefined) {
|
|
324
|
+
if (title.trim() === "")
|
|
325
|
+
throw new Error("--title cannot be blank.");
|
|
326
|
+
shape.title = title.trim();
|
|
327
|
+
changes += 1;
|
|
328
|
+
}
|
|
329
|
+
const detail = values.flags.get("--detail");
|
|
330
|
+
if (detail !== undefined) {
|
|
331
|
+
shape.detail = detail;
|
|
332
|
+
changes += 1;
|
|
333
|
+
}
|
|
334
|
+
const owners = values.flags.get("--owners");
|
|
335
|
+
if (owners !== undefined) {
|
|
336
|
+
shape.owners = readOwnersList(owners);
|
|
337
|
+
changes += 1;
|
|
338
|
+
}
|
|
339
|
+
const owner = values.flags.get("--owner");
|
|
340
|
+
if (owner !== undefined) {
|
|
341
|
+
shape.ownerName = readOneOwner(owner);
|
|
342
|
+
changes += 1;
|
|
343
|
+
}
|
|
344
|
+
const due = values.flags.get("--due");
|
|
345
|
+
if (due !== undefined) {
|
|
346
|
+
shape.dueAt = isNone(due) ? null : readDate(due, "--due");
|
|
347
|
+
changes += 1;
|
|
348
|
+
}
|
|
349
|
+
const date = values.flags.get("--date");
|
|
350
|
+
if (date !== undefined) {
|
|
351
|
+
shape.occurredAt = isNone(date) ? null : readDate(date, "--date");
|
|
352
|
+
changes += 1;
|
|
353
|
+
}
|
|
354
|
+
const kind = values.flags.get("--kind");
|
|
355
|
+
if (kind !== undefined) {
|
|
356
|
+
shape.itemKind = readKind(kind, "--kind");
|
|
357
|
+
changes += 1;
|
|
358
|
+
}
|
|
359
|
+
if (changes === 0) {
|
|
360
|
+
throw new Error("`cockpit manager edit` has nothing to change. Give at least one of --title, --detail, --owners, --due (--kind, --date, --owner on a timeline item). A flag you leave out keeps its value; `none` clears owners, a due date or a date.");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function readLink(values, shape) {
|
|
364
|
+
const meeting = values.flags.get("--meeting");
|
|
365
|
+
const none = values.booleans.has("--none");
|
|
366
|
+
if (meeting && none)
|
|
367
|
+
throw new Error("--meeting and --none say opposite things. Give one.");
|
|
368
|
+
if (!meeting && !none)
|
|
369
|
+
throw new Error("`cockpit manager link` needs --meeting <timeline-id> to link, or --none to unlink.");
|
|
370
|
+
shape.timelineItemId = meeting ? readId(meeting, "A timeline item") : null;
|
|
371
|
+
}
|
|
@@ -43,6 +43,7 @@ export { parseUsageArgs } from "./local-args-tower-usage.js";
|
|
|
43
43
|
export { parseLogsArgs } from "./local-args-tower-logs.js";
|
|
44
44
|
export { parseBrainArgs } from "./local-args-tower-brain.js";
|
|
45
45
|
export { parseApprovalsArgs } from "./local-args-tower-approvals.js";
|
|
46
|
+
export { parseManagerArgs } from "./local-args-tower-manager.js";
|
|
46
47
|
export { parseImageArgs } from "./local-args-tower-image.js";
|
|
47
48
|
export { parseCareersArgs } from './local-args-tower-careers.js';
|
|
48
49
|
export { parseRenameArgs } from './local-args-tower-rename.js';
|
|
@@ -19,7 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
|
|
|
19
19
|
import { parseRootsArgs } from "./roots.js";
|
|
20
20
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
21
21
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
22
|
-
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseApprovalsArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
22
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseApprovalsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
23
23
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
24
24
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
25
25
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -146,6 +146,8 @@ export function parseLocalArgs(argv) {
|
|
|
146
146
|
return parseBrainArgs(argv.slice(1));
|
|
147
147
|
case "approvals":
|
|
148
148
|
return parseApprovalsArgs(argv.slice(1));
|
|
149
|
+
case "manager":
|
|
150
|
+
return parseManagerArgs(argv.slice(1));
|
|
149
151
|
case "image":
|
|
150
152
|
return parseImageArgs(argv.slice(1));
|
|
151
153
|
default:
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit manager` command shape (BLI-5006), beside `local-command-shapes-approvals.ts` for the
|
|
3
|
+
* same reason: `local-command-shapes.ts` sits at the readability ceiling.
|
|
4
|
+
*
|
|
5
|
+
* The parser (`local-args-tower-manager.ts`) turns the typed words into the fields a request
|
|
6
|
+
* carries, so `manager.ts` only builds paths and bodies. A field that is ABSENT is "leave it
|
|
7
|
+
* alone"; an explicit clear is `owners: []`, `ownerName: null`, `dueAt: null`, `occurredAt: null`.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -89,6 +89,47 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
89
89
|
"approvals_edit, approvals_reject, approvals_rules, approvals_rules_create, approvals_rules_revoke.",
|
|
90
90
|
],
|
|
91
91
|
],
|
|
92
|
+
[
|
|
93
|
+
"manager",
|
|
94
|
+
[
|
|
95
|
+
"Usage: cockpit manager <clients|client|run|list|queue|show|add|edit|done|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
|
|
96
|
+
"",
|
|
97
|
+
"Tower Manager's client actions and timeline items, from the terminal: the same things the manager page does.",
|
|
98
|
+
" cockpit manager clients every client, with its id",
|
|
99
|
+
" cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt",
|
|
100
|
+
" cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name>|--no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]",
|
|
101
|
+
" actions (default), or with --timeline the timeline; --status, --owner, --no-owner and --overdue (open actions due before today) are for actions, --kind for the timeline.",
|
|
102
|
+
" Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut.",
|
|
103
|
+
" cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it)",
|
|
104
|
+
" an empty queue does not mean everything is reviewed: list <client> --needs-review --status all, and list <client> --timeline --needs-review",
|
|
105
|
+
" cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids, what a sync changed",
|
|
106
|
+
" cockpit manager add <client> --title <text> [--owners \"A, B\"] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
|
|
107
|
+
" cockpit manager add <client> --timeline --kind meeting|document|milestone|internal --title <text> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
|
|
108
|
+
" no --date makes an undated timeline item, which lands in \"Needs a date\"",
|
|
109
|
+
" --source-url (an http(s) link) and --source-note say where it came from, as the web form's Source does; show prints them",
|
|
110
|
+
" cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners \"A, B\"|none]",
|
|
111
|
+
" on a timeline item: [--kind <kind>] [--date <date>|none] [--owner <one name>|none]",
|
|
112
|
+
" cockpit manager done <action> --version <v> tick an action off (an AI action you tick off counts as reviewed by you, and suggested changes it makes moot close); reopen <action> --version <v> open it again",
|
|
113
|
+
" cockpit manager link <action> --version <v> --meeting <timeline-id> link an action to the meeting it came from; --none unlinks it",
|
|
114
|
+
" cockpit manager approve <id> --version <v> [--timeline] [--restore] the AI's item is right: mark it reviewed; --restore brings a DISMISSED item back into the lists, marked reviewed by you (without it, a dismissed item is refused)",
|
|
115
|
+
" cockpit manager dismiss <id> --version <v> [--timeline] the AI's item is wrong: dismiss it (kept, hidden from the lists)",
|
|
116
|
+
" cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline] decide one suggested change; add-new makes it an item of its own",
|
|
117
|
+
" cockpit manager archive <id> --version <v> [--timeline] delete an item: it moves to the Deleted list (list --archived), kept; unarchive <id> --version <v> brings it back",
|
|
118
|
+
"approve, dismiss and proposal decide the AI's suggestion as YOU: your name is recorded as the reviewer, and the item leaves the review queue unless a suggested change still waits on it.",
|
|
119
|
+
"<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add are reviewed already.",
|
|
120
|
+
"--version is the item's version exactly as list or show prints it, copied as it is. Every change to an existing item names it: if someone or a sync changed the item",
|
|
121
|
+
"since, the write is refused with a sentence saying so and nothing is written; run show again for the new version and retry.",
|
|
122
|
+
"--owners on an action takes, as the web form says, \"One or more people, separated by commas\" (\"Rowan Ash, Juniper Twig\"), and it REPLACES the whole list. Leave it out and the owners stay;",
|
|
123
|
+
"--owners none clears them. A timeline item has ONE owner: --owner <name> or --owner none. The same pattern clears a date: --due none, --date none.",
|
|
124
|
+
"Owner names are kept as written, not matched to accounts: use the spelling `list` shows, so one person stays one name. A flag you leave out is never sent, and a flag given twice is refused. A date is YYYY-MM-DD. `cockpit manager`, `manager help` and `manager --help` print this and change nothing.",
|
|
125
|
+
"add always sends a request id (--request-id <uuid>, else a fresh one) and prints it. If Tower does not answer, nothing was confirmed: rerun the same command with",
|
|
126
|
+
"--request-id <id> and it retries without making a duplicate.",
|
|
127
|
+
"--json writes the door's body to stdout, verbatim, and nothing else (an add's body also carries requestId); a refusal is { ok: false, error, detail, refusal? } and exits 1.",
|
|
128
|
+
"Progress lines on stderr start with [tag]; stdout is the answer (2>/dev/null hides them). Creating a client, syncing, Rebuild brief and merging owners are on the manager page only. Teaching: docs/architecture/tower-manager.md.",
|
|
129
|
+
"The bli-tower MCP server has the twins: manager_clients, manager_client, manager_run, manager_list, manager_queue, manager_show, manager_add, manager_edit,",
|
|
130
|
+
"manager_done, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
|
|
131
|
+
],
|
|
132
|
+
],
|
|
92
133
|
[
|
|
93
134
|
"image",
|
|
94
135
|
[
|
|
@@ -87,6 +87,7 @@ export const rootCommandNames = new Set([
|
|
|
87
87
|
"logs",
|
|
88
88
|
"brain",
|
|
89
89
|
"approvals",
|
|
90
|
+
"manager",
|
|
90
91
|
"image",
|
|
91
92
|
...SURFACE_ALIASES.keys(),
|
|
92
93
|
]);
|
|
@@ -148,6 +149,7 @@ export function localCommandHelp(command) {
|
|
|
148
149
|
" cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
|
|
149
150
|
" cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
|
|
150
151
|
" cockpit approvals [list|show <id>|create|approve <id> [--edit]|edit <id> [--approve]|reject <id>|rules [--active true|false]|rules create <card-id> [--any-audience] [--label <text>]|rules revoke <rule-id> [--reason <text>]] [--status <s>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
|
|
152
|
+
" cockpit manager <clients|client <client>|run <run-id>|list <client>|queue <client>|show <id>|add <client> --title <t>|edit <id> --version <v>|done|reopen|link|approve|dismiss|proposal|archive|unarchive> [--timeline] [--dashboard-url <url>] [--json]",
|
|
151
153
|
" cockpit image <describe <file> [--session <id>]|show <sha256-or-prefix>> [--dashboard-url <url>] [--json]",
|
|
152
154
|
" cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
|
|
153
155
|
" cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -55,6 +55,7 @@ import { runUsage } from "./usage.js";
|
|
|
55
55
|
import { runSpeed } from "./speed.js";
|
|
56
56
|
import { runLogs } from "./logs.js";
|
|
57
57
|
import { runApprovals } from "./approvals.js";
|
|
58
|
+
import { runManager } from "./manager.js";
|
|
58
59
|
import { runImage } from "./image.js";
|
|
59
60
|
import { runBrain } from "./brain.js";
|
|
60
61
|
import { parseLocalArgs } from "./local-args.js";
|
|
@@ -228,21 +229,15 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
|
|
|
228
229
|
return await runSourcesCheck(command, io);
|
|
229
230
|
case "glossary":
|
|
230
231
|
return await runGlossary(command, io);
|
|
231
|
-
case "docs":
|
|
232
|
-
|
|
233
|
-
case "
|
|
234
|
-
return await runMsg(command, io);
|
|
235
|
-
case "issue":
|
|
236
|
-
return await runIssue(command, io);
|
|
232
|
+
case "docs": return await runDocs(command, io);
|
|
233
|
+
case "msg": return await runMsg(command, io);
|
|
234
|
+
case "issue": return await runIssue(command, io);
|
|
237
235
|
case "cal": return runCal(command, io);
|
|
238
|
-
case "mail":
|
|
239
|
-
return await runMail(command, io);
|
|
236
|
+
case "mail": return await runMail(command, io);
|
|
240
237
|
case "project": return await runProject(command, io);
|
|
241
238
|
case "search": return await runSearch(command, io);
|
|
242
|
-
case "github":
|
|
243
|
-
|
|
244
|
-
case "release":
|
|
245
|
-
return await runRelease(command, io);
|
|
239
|
+
case "github": return await runGithub(command, io);
|
|
240
|
+
case "release": return await runRelease(command, io);
|
|
246
241
|
case "careers": return await runCareers(command, io);
|
|
247
242
|
case "rename": return await runRename(command, io);
|
|
248
243
|
case "setup": return await runSetup(command, io);
|
|
@@ -251,6 +246,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
|
|
|
251
246
|
case "logs": return await runLogs(command, io);
|
|
252
247
|
case "brain": return await runBrain(command, io);
|
|
253
248
|
case "approvals": return await runApprovals(command, io);
|
|
249
|
+
case "manager": return await runManager(command, io);
|
|
254
250
|
case "image": return await runImage(command, io);
|
|
255
251
|
}
|
|
256
252
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `cockpit manager` prints clients, items and a decision for a person (BLI-5006).
|
|
3
|
+
*
|
|
4
|
+
* The words are the door's own (a title, an owner, a proposal's fields, a sync's reason); this file
|
|
5
|
+
* only lays them out. Every item ends with a dim `id` line and its `version`, because the verbs take
|
|
6
|
+
* both: an id to name the item, and the version (its `updatedAt`) so a write cannot land on top of a
|
|
7
|
+
* change nobody read. Readable, not exhaustive: `--json` is the whole answer and never comes through here.
|
|
8
|
+
*/
|
|
9
|
+
import { dim } from "./cli-io.js";
|
|
10
|
+
const QUEUE_REASON = {
|
|
11
|
+
added: "added by a sync, not looked at yet",
|
|
12
|
+
changed: "changed by a sync since you last saw it",
|
|
13
|
+
proposal: "has a suggested change waiting",
|
|
14
|
+
};
|
|
15
|
+
export function clientLines(clients, color) {
|
|
16
|
+
if (clients.length === 0)
|
|
17
|
+
return ["Tower has no clients yet."];
|
|
18
|
+
return clients.flatMap((client) => [
|
|
19
|
+
`${client.name} [${client.enabled === false ? "disabled" : client.onboardingStatus}]${client.running ? " [reading now]" : ""}`,
|
|
20
|
+
` ${client.lastSyncedAt ? `last synced ${client.lastSyncedAt}` : "never synced"}${client.pagesWaiting ? `, ${client.pagesWaiting} pages waiting to be read` : ""}`,
|
|
21
|
+
dim(` id ${client.id}`, color),
|
|
22
|
+
"",
|
|
23
|
+
]).slice(0, -1);
|
|
24
|
+
}
|
|
25
|
+
/** "needs review" when nobody has looked at it; a suggested change waiting is said too. */
|
|
26
|
+
export function reviewLabel(item) {
|
|
27
|
+
const parts = [item.reviewStatus === "unreviewed" ? "needs review" : item.reviewStatus === "rejected" ? "dismissed" : "reviewed"];
|
|
28
|
+
const proposals = item.proposedChanges?.length ?? 0;
|
|
29
|
+
if (proposals > 0)
|
|
30
|
+
parts.push(`${proposals} suggested change${proposals === 1 ? "" : "s"} waiting`);
|
|
31
|
+
if (item.syncChange)
|
|
32
|
+
parts.push("changed by sync");
|
|
33
|
+
if (item.archivedAt)
|
|
34
|
+
parts.push("archived");
|
|
35
|
+
return parts.join(", ");
|
|
36
|
+
}
|
|
37
|
+
/** "owners Pip Moss, Rowan Ash" / "owner Pip Moss", or "no owner" (never a person called "nobody"). */
|
|
38
|
+
function ownersText(item, timeline) {
|
|
39
|
+
const names = item.owners ?? (item.ownerName ? [item.ownerName] : []);
|
|
40
|
+
if (names.length === 0)
|
|
41
|
+
return "no owner";
|
|
42
|
+
return `${timeline || names.length === 1 ? "owner" : "owners"} ${names.join(", ")}`;
|
|
43
|
+
}
|
|
44
|
+
function whenText(item, timeline) {
|
|
45
|
+
if (timeline)
|
|
46
|
+
return item.occurredAt ? item.occurredAt : "no date (it sits in Needs a date)";
|
|
47
|
+
if (!item.dueAt)
|
|
48
|
+
return "no due date";
|
|
49
|
+
return `due ${item.dueAt}${item.overdue ? " (overdue)" : ""}`;
|
|
50
|
+
}
|
|
51
|
+
/** The short row: title, who, when, status, review state, and the id and version the verbs take. */
|
|
52
|
+
export function itemRowLines(item, timeline, color) {
|
|
53
|
+
const review = reviewLabel(item);
|
|
54
|
+
const facts = timeline
|
|
55
|
+
? [item.kind ?? "item", whenText(item, true), ownersText(item, true)]
|
|
56
|
+
: [ownersText(item, false), whenText(item, false), item.status ?? "open"];
|
|
57
|
+
return [`${item.title} [${review}]`, ` ${facts.join(" | ")}`, dim(` id ${item.id} version ${item.updatedAt}`, color)];
|
|
58
|
+
}
|
|
59
|
+
export function listLines(list, timeline, color) {
|
|
60
|
+
const noun = timeline ? "timeline item" : "action";
|
|
61
|
+
const shown = list.items.length;
|
|
62
|
+
const head = shown < list.total ? `Showing ${shown} of ${list.total} ${noun}s.` : `${list.total} ${noun}${list.total === 1 ? "" : "s"}.`;
|
|
63
|
+
const c = list.counts;
|
|
64
|
+
const cut = list.truncated ? " A list holds at most 500 rows: narrow it with --status, --owner, --kind, --search or --needs-review." : "";
|
|
65
|
+
const lines = [`${head}${cut}`, `The client has ${c.openActions} open ${c.openActions === 1 ? "action" : "actions"} (${c.overdue} overdue) and ${c.toReview} ${c.toReview === 1 ? "item" : "items"} to review.`];
|
|
66
|
+
if (shown === 0)
|
|
67
|
+
lines.push(`No ${noun}s match.`);
|
|
68
|
+
for (const item of list.items)
|
|
69
|
+
lines.push("", ...itemRowLines(item, timeline, color));
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
export function queueLines(queue, color) {
|
|
73
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
74
|
+
const lines = [`${count(queue.total.timeline, "timeline item")} and ${count(queue.total.actions, "action")} from syncs after onboarding are waiting for a person.`];
|
|
75
|
+
if (queue.truncated)
|
|
76
|
+
lines.push(`Showing ${queue.timeline.length} and ${queue.actions.length}: the oldest first.`);
|
|
77
|
+
for (const [heading, items, timeline] of [["Timeline items", queue.timeline, true], ["Actions", queue.actions, false]]) {
|
|
78
|
+
if (items.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
lines.push("", `${heading}:`);
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
const rows = itemRowLines(item, timeline, color);
|
|
83
|
+
lines.push("", rows[0], ` ${QUEUE_REASON[item.queuedFor ?? ""] ?? "waiting for review"}`, ...rows.slice(1));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// The queue leaves out the onboarding's own items by design, so an empty one is not "all reviewed".
|
|
87
|
+
lines.push("", "Everything not yet reviewed, onboarding included: `cockpit manager list <client> --needs-review --status all` and `list <client> --timeline --needs-review`. `show <id>` lists an item's suggested changes.");
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
function valueText(value, label) {
|
|
91
|
+
if (value === null || value === undefined || (Array.isArray(value) && value.length === 0))
|
|
92
|
+
return "(none)";
|
|
93
|
+
const text = Array.isArray(value) ? value.join(", ") : value;
|
|
94
|
+
return label ? `${label} (${text})` : text;
|
|
95
|
+
}
|
|
96
|
+
function sourceLine(source) {
|
|
97
|
+
if (source.addedBy === "human")
|
|
98
|
+
return `from a person: ${[source.url, source.note].filter(Boolean).join(" | ") || "(empty)"}`;
|
|
99
|
+
const page = source.pageTitle ?? source.pageUrl ?? "a page";
|
|
100
|
+
return `${page}${source.quote ? `: "${source.quote}"` : ""}${source.verified === false ? " (not found in the page)" : ""}`;
|
|
101
|
+
}
|
|
102
|
+
/** One item in full: the detail, the sources, every suggested change with its id, and what a sync changed and why. */
|
|
103
|
+
export function itemDetailLines(detail, timeline, color) {
|
|
104
|
+
const item = detail.item;
|
|
105
|
+
const lines = [...itemRowLines(item, timeline, color)];
|
|
106
|
+
if (!timeline && detail.timelineItem)
|
|
107
|
+
lines.push(` From the meeting: ${detail.timelineItem.title}${detail.timelineItem.occurredAt ? ` (${detail.timelineItem.occurredAt})` : ""}`);
|
|
108
|
+
if (timeline && detail.actions && detail.actions.length > 0)
|
|
109
|
+
lines.push(` ${detail.actions.length} action${detail.actions.length === 1 ? "" : "s"} came from it: ${detail.actions.map((action) => action.title).join("; ")}`);
|
|
110
|
+
if (item.detail)
|
|
111
|
+
lines.push("", "Detail:", ...item.detail.split("\n").map((line) => ` ${line}`));
|
|
112
|
+
const unverified = item.unverifiedSourceCount ?? 0;
|
|
113
|
+
const sources = item.sourceCount ?? detail.evidence?.length ?? 0;
|
|
114
|
+
lines.push("", `Sources: ${sources === 0 ? (item.origin === "human" ? "none (a person added it)" : "none") : sources}${unverified > 0 ? ` (${unverified} quote${unverified === 1 ? "" : "s"} not found in a page the AI was given)` : ""}`);
|
|
115
|
+
for (const source of (detail.evidence ?? []))
|
|
116
|
+
lines.push(` ${sourceLine(source)}`);
|
|
117
|
+
const proposals = item.proposedChanges ?? [];
|
|
118
|
+
if (proposals.length > 0) {
|
|
119
|
+
lines.push("", "Suggested changes waiting for you:");
|
|
120
|
+
for (const proposal of proposals) {
|
|
121
|
+
const fields = Object.entries(proposal.fields).map(([name, value]) => `${name}: ${valueText(value, proposal.labels?.[name])}`);
|
|
122
|
+
lines.push(` ${proposal.id} (${proposal.kind}): ${fields.join("; ") || "no field change"}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push(` Decide one with: cockpit manager proposal <id> <proposal id> accept|dismiss|add-new --version <version>${timeline ? " --timeline" : ""}`);
|
|
125
|
+
}
|
|
126
|
+
const change = item.syncChange;
|
|
127
|
+
if (change) {
|
|
128
|
+
lines.push("", `Changed by sync, ${change.at}${change.reason ? `: ${change.reason}` : ""}`);
|
|
129
|
+
for (const [name, field] of Object.entries(change.fields)) {
|
|
130
|
+
lines.push(` ${name}: ${valueText(field.from, field.fromLabel)} -> ${valueText(field.to, field.toLabel)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
135
|
+
/** One client in full: its state, its last read and how many pages wait. */
|
|
136
|
+
export function clientDetailLines(client, color) {
|
|
137
|
+
const lines = [...clientLines([client], color)];
|
|
138
|
+
if (client.notionRootUrl)
|
|
139
|
+
lines.push(` Notion root: ${client.notionRootUrl}`);
|
|
140
|
+
if (client.queued)
|
|
141
|
+
lines.push(" Queued behind another run (Sync all).");
|
|
142
|
+
if (client.storedPages !== undefined)
|
|
143
|
+
lines.push(` ${client.storedPages} stored pages${client.incompletePages ? `, ${client.incompletePages} only partly handed over by Notion` : ""}`);
|
|
144
|
+
const read = client.lastRead;
|
|
145
|
+
if (read) {
|
|
146
|
+
lines.push(` Last read: ${read.kind}, ${read.reason}, started ${read.startedAt}${read.finishedAt ? `, finished ${read.finishedAt}` : ", still running"}`);
|
|
147
|
+
lines.push(` ${read.pagesSeen} pages seen, ${read.pagesChanged} changed; ${read.opsApplied} changes applied, ${read.opsProposed} proposed, ${read.opsRejected} refused`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
lines.push(" No read yet.");
|
|
151
|
+
}
|
|
152
|
+
const rebuild = client.lastBriefRebuild;
|
|
153
|
+
if (rebuild)
|
|
154
|
+
lines.push(` Last Rebuild brief: ${rebuild.reason}, started ${rebuild.startedAt}, ${rebuild.pages} pages, ${rebuild.briefWritten ? "brief replaced" : "old brief kept"}`);
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
/** One run's receipt: what it read, what it changed, and why it ended. */
|
|
158
|
+
export function runLines(run) {
|
|
159
|
+
return [
|
|
160
|
+
`${run.kind} run: ${run.reason}`,
|
|
161
|
+
` started ${run.startedAt}${run.finishedAt ? `, finished ${run.finishedAt}` : ", still running"}`,
|
|
162
|
+
` ${run.pagesSeen} pages seen, ${run.pagesChanged} changed, ${run.pagesFetched} fetched, ${run.pagesArchived} archived; ${run.versionsConsumed} versions read`,
|
|
163
|
+
` ${run.opsApplied} changes applied, ${run.opsProposed} proposed, ${run.opsRejected} refused${run.batchNo !== null ? ` (batch ${run.batchNo})` : ""}`,
|
|
164
|
+
...(run.model ? [` model ${run.model}${run.tokensIn !== null ? `, ${run.tokensIn} tokens in, ${run.tokensOut ?? 0} out` : ""}`] : []),
|
|
165
|
+
...(run.clientId ? [` client ${run.clientId}`] : []),
|
|
166
|
+
` id ${run.id}`,
|
|
167
|
+
];
|
|
168
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const BASE = "/api/manager";
|
|
2
|
+
const enc = encodeURIComponent;
|
|
3
|
+
export function planManagerRequest(command, clientId, newRequestId) {
|
|
4
|
+
const table = command.timeline ? "timeline" : "actions";
|
|
5
|
+
const item = `${BASE}/${table}/${enc(command.id ?? "")}`;
|
|
6
|
+
const seen = { expectedUpdatedAt: command.version };
|
|
7
|
+
const write = (method, path, body) => ({ method, path, body, logFields: itemLog(table) });
|
|
8
|
+
switch (command.action) {
|
|
9
|
+
case "clients":
|
|
10
|
+
return { method: "GET", path: `${BASE}/clients`, logFields: (a) => ({ clients: arrayLength(a, "clients") }) };
|
|
11
|
+
case "client":
|
|
12
|
+
return { method: "GET", path: `${BASE}/clients/${enc(clientId ?? "")}`, logFields: () => ({}) };
|
|
13
|
+
case "run":
|
|
14
|
+
return { method: "GET", path: `${BASE}/runs/${enc(command.runId ?? "")}`, logFields: () => ({}) };
|
|
15
|
+
case "list":
|
|
16
|
+
return { method: "GET", path: `${BASE}/clients/${enc(clientId ?? "")}/${table}${listQuery(command)}`, logFields: (a) => ({ table, returned: arrayLength(a, "items"), total: numberField(a, "total") }) };
|
|
17
|
+
case "queue":
|
|
18
|
+
return { method: "GET", path: `${BASE}/clients/${enc(clientId ?? "")}/review-queue`, logFields: (a) => ({ timeline: arrayLength(a, "timeline"), actions: arrayLength(a, "actions") }) };
|
|
19
|
+
case "show":
|
|
20
|
+
return { method: "GET", path: item, logFields: itemLog(table) };
|
|
21
|
+
case "add": {
|
|
22
|
+
const path = `${BASE}/clients/${enc(clientId ?? "")}/${table}`;
|
|
23
|
+
const requestId = command.requestId ?? newRequestId();
|
|
24
|
+
const common = {
|
|
25
|
+
title: command.title,
|
|
26
|
+
...(command.detail === undefined ? {} : { detail: command.detail }),
|
|
27
|
+
...(command.sourceUrl ? { sourceUrl: command.sourceUrl } : {}),
|
|
28
|
+
...(command.sourceNote ? { sourceNote: command.sourceNote } : {}),
|
|
29
|
+
requestId,
|
|
30
|
+
};
|
|
31
|
+
if (command.timeline) {
|
|
32
|
+
return { ...write("POST", path, { kind: command.itemKind, ...common, ...(command.occurredAt ? { occurredAt: command.occurredAt } : {}), ...(command.ownerName ? { ownerName: command.ownerName } : {}) }), requestId };
|
|
33
|
+
}
|
|
34
|
+
return { ...write("POST", path, { ...common, ...(command.owners ? { owners: command.owners } : {}), ...(command.dueAt ? { dueAt: command.dueAt } : {}), ...(command.timelineItemId ? { timelineItemId: command.timelineItemId } : {}) }), requestId };
|
|
35
|
+
}
|
|
36
|
+
case "edit": {
|
|
37
|
+
const fields = {};
|
|
38
|
+
if (command.title !== undefined)
|
|
39
|
+
fields["title"] = command.title;
|
|
40
|
+
if (command.detail !== undefined)
|
|
41
|
+
fields["detail"] = command.detail;
|
|
42
|
+
if (command.itemKind !== undefined)
|
|
43
|
+
fields["kind"] = command.itemKind;
|
|
44
|
+
if (command.dueAt !== undefined)
|
|
45
|
+
fields["dueAt"] = command.dueAt;
|
|
46
|
+
if (command.occurredAt !== undefined)
|
|
47
|
+
fields["occurredAt"] = command.occurredAt;
|
|
48
|
+
if (command.owners !== undefined)
|
|
49
|
+
fields["owners"] = command.owners;
|
|
50
|
+
if (command.ownerName !== undefined)
|
|
51
|
+
fields["ownerName"] = command.ownerName;
|
|
52
|
+
return write("PATCH", item, { ...seen, ...fields });
|
|
53
|
+
}
|
|
54
|
+
case "done": return write("POST", `${item}/status`, { ...seen, status: "done" });
|
|
55
|
+
case "reopen": return write("POST", `${item}/status`, { ...seen, status: "open" });
|
|
56
|
+
case "link": return write("POST", `${item}/link`, { ...seen, timelineItemId: command.timelineItemId ?? null });
|
|
57
|
+
case "approve": return write("POST", `${item}/review`, { ...seen, decision: "accept" });
|
|
58
|
+
case "dismiss": return write("POST", `${item}/review`, { ...seen, decision: "dismiss" });
|
|
59
|
+
case "proposal": return write("POST", `${item}/proposal`, { ...seen, decision: command.decision, proposalId: command.proposalId });
|
|
60
|
+
case "archive": return write("POST", `${item}/archive`, seen);
|
|
61
|
+
case "unarchive": return write("POST", `${item}/unarchive`, seen);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function listQuery(command) {
|
|
65
|
+
const query = new URLSearchParams();
|
|
66
|
+
if (command.timeline && command.kindFilter)
|
|
67
|
+
query.set("kind", command.kindFilter);
|
|
68
|
+
if (!command.timeline && command.status)
|
|
69
|
+
query.set("status", command.status);
|
|
70
|
+
if (!command.timeline && command.ownerFilter)
|
|
71
|
+
query.set("owner", command.ownerFilter);
|
|
72
|
+
if (!command.timeline && command.noOwner)
|
|
73
|
+
query.set("noOwner", "true");
|
|
74
|
+
if (!command.timeline && command.overdue)
|
|
75
|
+
query.set("overdue", "true");
|
|
76
|
+
if (command.review)
|
|
77
|
+
query.set("review", command.review);
|
|
78
|
+
if (command.archived)
|
|
79
|
+
query.set("archived", "true");
|
|
80
|
+
if (command.needsReview)
|
|
81
|
+
query.set("needsReview", "true");
|
|
82
|
+
if (command.search)
|
|
83
|
+
query.set("q", command.search);
|
|
84
|
+
const text = query.toString();
|
|
85
|
+
return text ? `?${text}` : "";
|
|
86
|
+
}
|
|
87
|
+
function itemLog(table) {
|
|
88
|
+
return (answer) => {
|
|
89
|
+
const item = answer && typeof answer === "object" ? answer.item : undefined;
|
|
90
|
+
return { table, item_id: item?.id ?? null, review_status: item?.reviewStatus ?? null };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function arrayLength(answer, key) {
|
|
94
|
+
const value = answer && typeof answer === "object" ? answer[key] : undefined;
|
|
95
|
+
return Array.isArray(value) ? value.length : null;
|
|
96
|
+
}
|
|
97
|
+
function numberField(answer, key) {
|
|
98
|
+
const value = answer && typeof answer === "object" ? answer[key] : undefined;
|
|
99
|
+
return typeof value === "number" ? value : null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit manager` (BLI-5006): Tower Manager's client actions and timeline items from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Plain words: Tower reads a client's Notion pages and keeps their actions and timeline. This lists
|
|
5
|
+
* them, adds and edits them, ticks them off, links an action to the meeting it came from, and
|
|
6
|
+
* decides the AI's suggestions, the same things the manager page does.
|
|
7
|
+
*
|
|
8
|
+
* Sixteen verbs, each one `/api/manager` door on this machine's device token, the same door the MCP
|
|
9
|
+
* twins (`manager_*`) use; the door passes Railway's answer through unchanged. Every write on an
|
|
10
|
+
* existing item carries `--version`, the item's `updatedAt` as `list`/`show` print it: a stale one is
|
|
11
|
+
* refused with the door's own sentence and nothing is written. `approve`, `dismiss` and `proposal`
|
|
12
|
+
* decide as the CALLER (their name is the one recorded). `approve` reads the item first: a dismissed one is brought back
|
|
13
|
+
* only with `--restore`. `--json` carries the door's body, verbatim.
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
17
|
+
import { colorEnabled, writeLine } from "./cli-io.js";
|
|
18
|
+
import { localCommandHelp } from "./local-help.js";
|
|
19
|
+
import { clientDetailLines, clientLines, itemDetailLines, itemRowLines, runLines, listLines, queueLines, } from "./manager-render.js";
|
|
20
|
+
import { planManagerRequest } from "./manager-requests.js";
|
|
21
|
+
const TAG = "[manager cli]";
|
|
22
|
+
/**
|
|
23
|
+
* Longer than the door waits for the manager server (50 s, `MANAGER_DOOR_TIMEOUT_MS`) and than the door's own
|
|
24
|
+
* platform limit (`maxDuration` 60 s), so a slow write comes back as the door's named 504 rather than this end
|
|
25
|
+
* giving up first while the write may still land.
|
|
26
|
+
*/
|
|
27
|
+
export const MANAGER_DEADLINE_MS = 65_000;
|
|
28
|
+
const staleHint = (id, timeline) => `Run \`cockpit manager show ${id}${timeline ? " --timeline" : ""}\` for the current version and try again.`;
|
|
29
|
+
/** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
|
|
30
|
+
/** The verbs that change an existing item. */
|
|
31
|
+
const WRITES = new Set(["edit", "done", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
|
|
32
|
+
const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
|
|
33
|
+
export async function runManager(command, io) {
|
|
34
|
+
if (command.help) {
|
|
35
|
+
writeLine(io.stderr, `${TAG} help printed ${JSON.stringify({ reason: "no_verb_or_help_asked" })}`);
|
|
36
|
+
writeLine(io.stdout, localCommandHelp("manager"));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
let door;
|
|
40
|
+
try {
|
|
41
|
+
door = await openAgentDoor("manager", command, io);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const detail = error instanceof Error ? error.message : "this machine is not paired with Tower";
|
|
45
|
+
writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "not_paired" })}`);
|
|
46
|
+
if (command.json)
|
|
47
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail }));
|
|
48
|
+
else
|
|
49
|
+
writeLine(io.stderr, detail);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
let clientId;
|
|
53
|
+
if (command.client !== undefined) {
|
|
54
|
+
const resolved = await resolveClient(door, command.client);
|
|
55
|
+
if (typeof resolved !== "string")
|
|
56
|
+
return resolved.exit;
|
|
57
|
+
clientId = resolved;
|
|
58
|
+
}
|
|
59
|
+
// Approving a dismissed item brings it back (the web's Restore): say so first, and write nothing unless --restore was given.
|
|
60
|
+
if (command.action === "approve") {
|
|
61
|
+
const stopped = await checkRestore(command, door);
|
|
62
|
+
if (stopped !== undefined)
|
|
63
|
+
return stopped;
|
|
64
|
+
}
|
|
65
|
+
const plan = planManagerRequest(command, clientId, randomUUID);
|
|
66
|
+
return execute(command, door, plan, colorEnabled(io));
|
|
67
|
+
}
|
|
68
|
+
async function execute(command, door, plan, color) {
|
|
69
|
+
const answer = await askAgentDoor(door, { path: plan.path, method: plan.method, label: `manager ${command.action}`, timeoutMs: MANAGER_DEADLINE_MS, ...(plan.body === undefined ? {} : { body: plan.body }) });
|
|
70
|
+
if (!answer.ok)
|
|
71
|
+
return refuse(door, answer, command, plan.requestId);
|
|
72
|
+
writeLine(door.io.stderr, `${TAG} ${command.action} answered ${JSON.stringify({ http_status: answer.httpStatus, ...(plan.requestId ? { request_id: plan.requestId } : {}), ...plan.logFields(answer.body) })}`);
|
|
73
|
+
// An add's JSON is the door's body plus the `requestId` it was sent under, so a retry can name it.
|
|
74
|
+
if (door.json)
|
|
75
|
+
return emitAgentDoor(door, plan.requestId && answer.body && typeof answer.body === "object" ? { ...answer.body, requestId: plan.requestId } : answer.body);
|
|
76
|
+
const out = door.io.stdout;
|
|
77
|
+
const lines = renderAnswer(command, answer.body, color);
|
|
78
|
+
if (plan.requestId)
|
|
79
|
+
lines.push(` request id ${plan.requestId} (to retry this add without making a duplicate, rerun it with --request-id ${plan.requestId})`);
|
|
80
|
+
for (const line of lines)
|
|
81
|
+
writeLine(out, line);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
/** What `approve` needs to know before it writes: is the item dismissed? A number is the exit code of a refusal; undefined means go on and write. */
|
|
85
|
+
async function checkRestore(command, door) {
|
|
86
|
+
const read = { ...command, action: "show" };
|
|
87
|
+
const plan = planManagerRequest(read, undefined, randomUUID);
|
|
88
|
+
const answer = await askAgentDoor(door, { path: plan.path, method: plan.method, label: "manager approve (read)", timeoutMs: MANAGER_DEADLINE_MS });
|
|
89
|
+
if (!answer.ok)
|
|
90
|
+
return refuse(door, answer, read);
|
|
91
|
+
const dismissed = answer.body?.item?.reviewStatus === "rejected";
|
|
92
|
+
writeLine(door.io.stderr, `${TAG} approve read the item ${JSON.stringify({ http_status: answer.httpStatus, dismissed, restore: command.restore === true })}`);
|
|
93
|
+
if (dismissed && command.restore !== true) {
|
|
94
|
+
return failAgentDoor(door, TAG, "restore_needed", "This item is dismissed. Approving it brings it back into the lists, marked reviewed by you. To do that, run the same command with --restore.");
|
|
95
|
+
}
|
|
96
|
+
if (!dismissed && command.restore === true) {
|
|
97
|
+
return failAgentDoor(door, TAG, "not_dismissed", "--restore is only for a dismissed item; this one is not dismissed. Run approve without --restore.");
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
function renderAnswer(command, body, color) {
|
|
102
|
+
const timeline = command.timeline;
|
|
103
|
+
switch (command.action) {
|
|
104
|
+
case "clients": return clientLines(body.clients, color);
|
|
105
|
+
case "client": return clientDetailLines(body, color);
|
|
106
|
+
case "run": return runLines(body);
|
|
107
|
+
case "list": return listLines(body, timeline, color);
|
|
108
|
+
case "queue": return queueLines(body, color);
|
|
109
|
+
case "show": return itemDetailLines(body, timeline, color);
|
|
110
|
+
default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color)];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** What a write did, in one sentence a person reads; the item's new version follows on its row. */
|
|
114
|
+
function writeSentence(command, body) {
|
|
115
|
+
const as = "as you";
|
|
116
|
+
// Approving the item or deciding one change leaves the queue only when no other suggested change waits on it.
|
|
117
|
+
const waiting = body.item?.proposedChanges?.length ?? 0;
|
|
118
|
+
const queue = waiting === 0 ? "It leaves the review queue." : `${waiting === 1 ? "Its other suggested change still waits" : `Its ${waiting} other suggested changes still wait`} in the review queue.`;
|
|
119
|
+
switch (command.action) {
|
|
120
|
+
case "add": return [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
|
|
121
|
+
case "edit": return ["Saved your changes."];
|
|
122
|
+
// Railway writes nothing when the action already has that status, so its version comes back unchanged.
|
|
123
|
+
case "done": return [body.item?.updatedAt === command.version ? "Already done; nothing changed." : "Marked done."];
|
|
124
|
+
case "reopen": return [body.item?.updatedAt === command.version ? "Already open; nothing changed." : "Reopened."];
|
|
125
|
+
case "link": return [command.timelineItemId ? `Linked to ${body.timelineItem?.title ?? "that meeting"}.` : "Unlinked from its meeting."];
|
|
126
|
+
case "approve": return [command.restore ? `Restored: ${body.item?.title ?? "that item"}, back in the lists and marked reviewed by you.` : `Approved ${as}. ${queue.replace("other ", "")}`];
|
|
127
|
+
case "dismiss": return [`Dismissed ${as}. It leaves the review queue.`];
|
|
128
|
+
case "proposal": return [
|
|
129
|
+
command.decision === "add_as_new" && body.added ? `Decided ${as}: added as a new item, ${body.added.title} (id ${body.added.id}).` : `Decided ${as}: ${command.decision === "accept" ? "accepted" : command.decision === "dismiss" ? "dismissed" : "added as a new item"}. ${queue}`,
|
|
130
|
+
];
|
|
131
|
+
case "archive": return ["Archived (deleted from the lists; `unarchive` brings it back)."];
|
|
132
|
+
case "unarchive": return ["Brought back."];
|
|
133
|
+
default: return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** The client as an id, or its exact name (case-insensitive) looked up in `clients`; anything else is refused by name. */
|
|
137
|
+
async function resolveClient(door, ref) {
|
|
138
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref))
|
|
139
|
+
return ref;
|
|
140
|
+
const answer = await askAgentDoor(door, { path: "/api/manager/clients", method: "GET", label: "manager clients", timeoutMs: MANAGER_DEADLINE_MS });
|
|
141
|
+
if (!answer.ok)
|
|
142
|
+
return { exit: refuse(door, answer, undefined) };
|
|
143
|
+
const clients = answer.body.clients;
|
|
144
|
+
const wanted = ref.trim().toLowerCase();
|
|
145
|
+
const matches = clients.filter((client) => client.name.trim().toLowerCase() === wanted);
|
|
146
|
+
if (matches.length === 1) {
|
|
147
|
+
writeLine(door.io.stderr, `${TAG} client resolved ${JSON.stringify({ client_id: matches[0].id, by: "name", clients: clients.length })}`);
|
|
148
|
+
return matches[0].id;
|
|
149
|
+
}
|
|
150
|
+
const names = clients.map((client) => client.name).join(", ");
|
|
151
|
+
const detail = matches.length === 0
|
|
152
|
+
? `No client is named "${ref}". ${clients.length === 0 ? "Tower has no clients yet." : `Clients: ${names}. (\`cockpit manager clients\` lists them with their ids.)`}`
|
|
153
|
+
: `${matches.length} clients are named "${ref}". Use the id instead: ${matches.map((client) => client.id).join(", ")}.`;
|
|
154
|
+
return { exit: failAgentDoor(door, TAG, matches.length === 0 ? "client_not_found" : "client_name_ambiguous", detail, { clients: clients.length, matches: matches.length }) };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A refusal keeps Railway's own sentence: `message` (the door's own shape), else `error` when `reason`
|
|
158
|
+
* is a string too (Railway's `{ error, reason, refusal? }`). The label is `reason`; `refusal` is the finer
|
|
159
|
+
* label, shown beside it on stderr and in the `--json` object.
|
|
160
|
+
*/
|
|
161
|
+
/** What to do next after a refusal that is not "read it again", so every refusal ends with a way forward. */
|
|
162
|
+
function nextStep(reason, refusal, command) {
|
|
163
|
+
const id = command?.id ?? "<id>";
|
|
164
|
+
const flag = command?.timeline ? " --timeline" : "";
|
|
165
|
+
if (reason === "not_found" && command?.id) {
|
|
166
|
+
return command.timeline
|
|
167
|
+
? " Check the id with `cockpit manager list <client> --timeline`; for an action, drop --timeline."
|
|
168
|
+
: ` Check the id with \`cockpit manager list <client>\`; for a timeline item, add --timeline${command.action === "done" || command.action === "reopen" || command.action === "link" ? " (done, reopen and link are for actions only)" : ""}.`;
|
|
169
|
+
}
|
|
170
|
+
// The door's key check (no such key, or a revoked one): this machine needs signing in again.
|
|
171
|
+
if (reason === "http_401")
|
|
172
|
+
return " Sign this machine in to Tower again with `cockpit login`, then retry.";
|
|
173
|
+
if (reason === "gateway_unreachable")
|
|
174
|
+
return " Check the address (--dashboard-url, or the one `cockpit login` saved) and your network, then try again.";
|
|
175
|
+
if ((reason === "manager_unreachable" || reason === "manager_timeout") && (command === undefined || !WRITES.has(command.action) && command.action !== "add")) {
|
|
176
|
+
return " Try again in a minute; if it keeps failing, Tower's manager server may be down.";
|
|
177
|
+
}
|
|
178
|
+
switch (refusal) {
|
|
179
|
+
// Another person's or another client's add already used this id: a fresh one makes the item.
|
|
180
|
+
case "request_reused": return " Run the add again without --request-id to make a new item.";
|
|
181
|
+
case "proposal_replaced": return ` Run \`cockpit manager show ${id}${flag}\` to see the suggested changes waiting and their ids.`;
|
|
182
|
+
case "item_archived": return ` Bring it back first: \`cockpit manager unarchive ${id}${flag} --version <v>\` (\`list <client>${flag} --archived\` prints its version).`;
|
|
183
|
+
case "item_dismissed": return ` Restore it first: \`cockpit manager approve ${id}${flag} --version <v> --restore\` (\`list <client>${flag} --review dismissed\` prints its version).`;
|
|
184
|
+
case "person_item_not_dismissable": return ` To delete an item a person added, use \`cockpit manager archive ${id}${flag} --version <v>\`.`;
|
|
185
|
+
case "timeline_not_live": return " Link to a live timeline item of the same client: `cockpit manager list <client> --timeline` lists them.";
|
|
186
|
+
case "item_not_archived": return " It is already in the lists; nothing to bring back.";
|
|
187
|
+
default: return "";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function refuse(door, answer, command, requestId) {
|
|
191
|
+
const body = answer.body && typeof answer.body === "object" ? answer.body : {};
|
|
192
|
+
const text = (value) => (typeof value === "string" && value.trim() !== "" ? value : undefined);
|
|
193
|
+
const sentence = text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
|
|
194
|
+
const refusal = text(body["refusal"]);
|
|
195
|
+
const reseen = answer.reason === "invalid_state" && (refusal === undefined || RESEEN_REFUSALS.has(refusal));
|
|
196
|
+
const hint = reseen && command?.id
|
|
197
|
+
? ` ${staleHint(command.id, command.timeline)}`
|
|
198
|
+
: nextStep(answer.reason, refusal, command);
|
|
199
|
+
// An add with no answer from Tower (or a server error) may or may not have been made: the same request id makes the retry safe.
|
|
200
|
+
const unconfirmed = requestId !== undefined && (answer.httpStatus === undefined || answer.httpStatus >= 500);
|
|
201
|
+
const noAnswer = answer.httpStatus === undefined || answer.httpStatus >= 500;
|
|
202
|
+
// A change to an existing item with no answer may or may not have landed: look before trying again.
|
|
203
|
+
const changedUnknown = !unconfirmed && noAnswer && command?.id !== undefined && WRITES.has(command.action);
|
|
204
|
+
const retry = unconfirmed
|
|
205
|
+
? ` Nothing was confirmed: Tower did not say whether the item was made. Rerun the same command with \`--request-id ${requestId}\` to retry without making a duplicate.`
|
|
206
|
+
: changedUnknown
|
|
207
|
+
? ` It may or may not have been saved: run \`cockpit manager show ${command.id}${command.timeline ? " --timeline" : ""}\` to see, and use the version it prints if you try again.`
|
|
208
|
+
: "";
|
|
209
|
+
const stop = hint || retry ? (/[.!?]$/.test(sentence) ? "" : ".") : "";
|
|
210
|
+
return failAgentDoor(door, TAG, answer.reason, `${sentence}${stop}${hint}${retry}`, { ...(refusal ? { refusal } : {}), ...(unconfirmed ? { requestId } : {}) });
|
|
211
|
+
}
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.213");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.213",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.66",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.122",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.75"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|