@nurix/nustack 0.23.3 → 0.23.5

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.
@@ -0,0 +1,183 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { openLocalStore } from "../lib/local_store/database.js";
3
+ import { idempotencyKeyOf, splitObligationKey } from "../lib/records/naturalKey.js";
4
+ import { findRecordByKey } from "../lib/records/recordsTable.js";
5
+ import { emitRows, refuseEmit, resolveCaller } from "./records.js";
6
+ export const RECORD_ALIAS_GROUPS = ["obligation", "backlog", "changelog", "journal"];
7
+ const VERDICTS_NEEDING_REASON = new Set(["owes_nothing", "blocked", "dismissed"]);
8
+ const PLAN_ANCHOR = /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
9
+ function requestOf(command, kind, rows, options) {
10
+ return {
11
+ command,
12
+ target: command,
13
+ kind,
14
+ rows,
15
+ json: options.json === true,
16
+ ...(options.root !== undefined ? { root: options.root } : {}),
17
+ ...(options.project !== undefined ? { project: options.project } : {}),
18
+ ...(options.session !== undefined ? { session: options.session } : {}),
19
+ };
20
+ }
21
+ function refusal(command, options, message, globals) {
22
+ return refuseEmit(requestOf(command, "obligation", [], options), "VALIDATION_FAILED", "records-validation", message, globals);
23
+ }
24
+ export async function runObligationAdd(options, globals) {
25
+ const command = "obligation add";
26
+ if (!options.plan && !options.origin)
27
+ return refusal(command, options, "obligation add takes --plan <anchor> or --origin <origin>", globals);
28
+ const payload = {
29
+ origin: options.origin ?? `plan:${options.plan}`,
30
+ type: options.type,
31
+ subject: options.subject,
32
+ ...(options.claim !== undefined ? { claim: options.claim } : {}),
33
+ ...(options.ownerAgent !== undefined ? { ownerAgentDefinitionId: options.ownerAgent } : {}),
34
+ state: "open",
35
+ };
36
+ await emitRows(requestOf(command, "obligation", [{ payload, ...(options.plan ? { planAnchor: options.plan } : {}) }], options), globals);
37
+ }
38
+ const COPIED_FIELDS = ["origin", "type", "subject", "claim", "ownerAgentDefinitionId", "isGateCounted"];
39
+ export async function runObligationDischarge(options, globals) {
40
+ const command = "obligation discharge";
41
+ const key = options.key ?? "";
42
+ if (options.outcome && VERDICTS_NEEDING_REASON.has(options.outcome) && !options.reason) {
43
+ return refusal(command, options, `--outcome ${options.outcome} needs a --reason`, globals);
44
+ }
45
+ const caller = await resolveCaller(options);
46
+ const store = await openLocalStore();
47
+ let stored;
48
+ try {
49
+ stored = findRecordByKey(store.db, idempotencyKeyOf(caller.projectId ?? caller.root, "obligation", key));
50
+ }
51
+ finally {
52
+ store.close();
53
+ }
54
+ let fields;
55
+ let planAnchor;
56
+ if (stored && stored.kind === "obligation") {
57
+ const storedPayload = JSON.parse(stored.payloadJson);
58
+ fields = {};
59
+ for (const field of COPIED_FIELDS)
60
+ if (storedPayload[field] !== undefined)
61
+ fields[field] = storedPayload[field];
62
+ planAnchor = stored.planAnchor;
63
+ }
64
+ else {
65
+ const split = splitObligationKey(key);
66
+ if (!split)
67
+ return refusal(command, options, "--key must be an obligation's natural key, <origin>#<type>:<subject>", globals);
68
+ fields = { ...split };
69
+ const plan = split.origin.startsWith("plan:") ? split.origin.slice("plan:".length) : null;
70
+ planAnchor = plan !== null && PLAN_ANCHOR.test(plan) ? plan : null;
71
+ }
72
+ const payload = {
73
+ ...fields,
74
+ state: "discharged",
75
+ outcome: { verdict: options.outcome, ...(options.reason !== undefined ? { reason: options.reason } : {}) },
76
+ };
77
+ await emitRows(requestOf(command, "obligation", [{ payload, ...(planAnchor !== null ? { planAnchor } : {}) }], options), globals);
78
+ }
79
+ export async function runBacklogAdd(options, globals) {
80
+ const payload = {
81
+ anchor: options.anchor,
82
+ priority: options.priority,
83
+ text: options.text,
84
+ ...(options.discoveredBy !== undefined ? { discoveredBy: options.discoveredBy } : {}),
85
+ ...(options.section !== undefined ? { section: options.section } : {}),
86
+ };
87
+ await emitRows(requestOf("backlog add", "backlog", [{ payload }], options), globals);
88
+ }
89
+ export async function runChangelogAdd(options, globals) {
90
+ const command = "changelog add";
91
+ if (!options.plan && !options.change)
92
+ return refusal(command, options, "changelog add takes --plan <anchor> or --change <change id>", globals);
93
+ let body = options.body;
94
+ if (body === undefined && options.bodyFile !== undefined) {
95
+ try {
96
+ body = await readFile(options.bodyFile, "utf8");
97
+ }
98
+ catch (error) {
99
+ return refusal(command, options, `--body-file could not be read: ${error instanceof Error ? error.message : String(error)}`, globals);
100
+ }
101
+ }
102
+ if (body === undefined)
103
+ return refusal(command, options, "changelog add takes --body <text> or --body-file <path>", globals);
104
+ const payload = {
105
+ origin: options.plan ? `plan:${options.plan}` : `change:${options.change}`,
106
+ title: options.title,
107
+ body,
108
+ ...(options.commit && options.commit.length > 0 ? { commits: options.commit } : {}),
109
+ };
110
+ await emitRows(requestOf(command, "changelog", [{ payload, ...(options.plan ? { planAnchor: options.plan } : {}) }], options), globals);
111
+ }
112
+ export async function runJournalAdd(options, globals) {
113
+ const command = "journal add";
114
+ const caller = await resolveCaller(options);
115
+ const sessionId = caller.refusalContext.sessionId;
116
+ if (!options.plan && !sessionId) {
117
+ return refusal(command, options, "journal add takes --plan <anchor>, or a session (--session or $NUSTACK_SESSION_ID)", globals);
118
+ }
119
+ const occurredAt = caller.refusalContext.now.toISOString();
120
+ const payload = {
121
+ origin: options.plan ? `plan:${options.plan}` : `session:${sessionId}`,
122
+ type: "documentation",
123
+ subject: `journal-${occurredAt}`,
124
+ claim: options.text,
125
+ isGateCounted: false,
126
+ state: "open",
127
+ };
128
+ await emitRows(requestOf(command, "obligation", [{ payload, occurredAt, ...(options.plan ? { planAnchor: options.plan } : {}) }], options), globals);
129
+ }
130
+ const collect = (value, previous = []) => [...previous, value];
131
+ function withCommon(command) {
132
+ return command
133
+ .option("--root <dir>", "the folder the record is about (default: the current directory)")
134
+ .option("--project <id>", "the Project, when the folder carries no binding marker")
135
+ .option("--session <id>", "the session the record belongs to (default: $NUSTACK_SESSION_ID)")
136
+ .option("--json", "emit the versioned machine envelope");
137
+ }
138
+ export function registerRecordAliases(program, getGlobalOptions) {
139
+ const obligation = program.command("obligation").description("Open and discharge obligations (harness records)");
140
+ withCommon(obligation
141
+ .command("add")
142
+ .description("Open an obligation under a plan or an origin")
143
+ .option("--plan <anchor>", "the plan the obligation belongs to (origin plan:<anchor>)")
144
+ .option("--origin <origin>", "the origin, when it is not a plan: plan:, change:, path:, session: or run:")
145
+ .requiredOption("--type <type>", "documentation, test, security, or an agent-owned type")
146
+ .requiredOption("--subject <text>", "what the obligation is about: a doc path, a row number, a finding target")
147
+ .option("--claim <text>", "what is owed")
148
+ .option("--owner-agent <slug>", "the agent definition slug that alone discharges it")).action(async (options) => runObligationAdd(options, getGlobalOptions()));
149
+ withCommon(obligation
150
+ .command("discharge")
151
+ .description("Discharge an obligation by its natural key, with a verdict")
152
+ .requiredOption("--key <naturalKey>", "the obligation's natural key, <origin>#<type>:<subject>")
153
+ .requiredOption("--outcome <verdict>", "updated, satisfied, fixed, owes_nothing, blocked or dismissed")
154
+ .option("--reason <text>", "why; required for owes_nothing, blocked and dismissed")).action(async (options) => runObligationDischarge(options, getGlobalOptions()));
155
+ withCommon(program
156
+ .command("backlog")
157
+ .description("Log backlog items (harness records)")
158
+ .command("add")
159
+ .description("Log one backlog item")
160
+ .requiredOption("--anchor <anchor>", "the item's stable id")
161
+ .requiredOption("--text <text>", "the item")
162
+ .requiredOption("--priority <priority>", "P0, P1, P2 or P3")
163
+ .option("--discovered-by <who>", "the plan or agent that found it")
164
+ .option("--section <name>", "the backlog section")).action(async (options) => runBacklogAdd(options, getGlobalOptions()));
165
+ withCommon(program
166
+ .command("changelog")
167
+ .description("Write changelog entries (harness records)")
168
+ .command("add")
169
+ .description("Write a changelog entry for a plan or a change; a later call supersedes it")
170
+ .option("--plan <anchor>", "the plan the entry describes")
171
+ .option("--change <id>", "the NuStack Change the entry describes")
172
+ .requiredOption("--title <line>", "the entry's one-line summary")
173
+ .option("--body <text>", "the entry's body")
174
+ .option("--body-file <path>", "read the entry's body from a file")
175
+ .option("--commit <sha>", "a commit the entry covers (repeatable)", collect)).action(async (options) => runChangelogAdd(options, getGlobalOptions()));
176
+ withCommon(program
177
+ .command("journal")
178
+ .description("Write journal entries (harness records)")
179
+ .command("add")
180
+ .description("Write a journal entry: a documentation obligation the gate never counts")
181
+ .requiredOption("--text <text>", "the entry")
182
+ .option("--plan <anchor>", "the plan the entry belongs to (else the session)")).action(async (options) => runJournalAdd(options, getGlobalOptions()));
183
+ }
@@ -1,8 +1,15 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { readDesktopSettings } from "../lib/desktop_settings.js";
2
3
  import { openLocalStore } from "../lib/local_store/database.js";
3
4
  import { stateDbPath } from "../lib/local_store/paths.js";
4
5
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
5
- import { RECORDS_BATCH_CAP, drainOutbox, drainStore, emitRecords, isRecordKind, listOutbox, listStored, resolveDoor, } from "../lib/records/door.js";
6
+ import { resolveProjectId } from "../lib/project_context.js";
7
+ import { isEmitKind } from "../lib/records/door.js";
8
+ import { acquireDrainLock, drainRecords, refreshDrainLock, releaseDrainLock, startDetachedDrain } from "../lib/records/drain.js";
9
+ import { DEVICE_TOKEN_VARIABLE, DRAIN_CHILD_VARIABLE, SESSION_ID_VARIABLE, isFlagSet, readVariable, recordCredentialOf, } from "../lib/records/environment.js";
10
+ import { UNKNOWN_KIND_MESSAGE, assembleRecord, parseRowsText, translateRow, } from "../lib/records/recordInput.js";
11
+ import { AgentOwnedError, OBLIGATION_AGENT_OWNED, countRecords, hasDueRecord, listRecords, releaseUnboundRows, writeRecords, } from "../lib/records/recordsTable.js";
12
+ import { logRefusals } from "../lib/records/refusalLog.js";
6
13
  async function readStdin() {
7
14
  if (process.stdin.isTTY)
8
15
  return "";
@@ -11,101 +18,180 @@ async function readStdin() {
11
18
  chunks.push(chunk);
12
19
  return Buffer.concat(chunks).toString("utf8");
13
20
  }
14
- function parseRows(raw) {
15
- let parsed;
16
- try {
17
- parsed = JSON.parse(raw);
18
- }
19
- catch {
20
- return null;
21
+ export async function resolveCaller(request) {
22
+ const root = request.root ?? process.cwd();
23
+ const project = await resolveProjectId(root, request.project ? { project: request.project } : {});
24
+ const projectId = project.ok ? project.projectId : null;
25
+ return {
26
+ root,
27
+ projectId,
28
+ refusalContext: {
29
+ projectId,
30
+ root,
31
+ sessionId: request.session ?? readVariable(SESSION_ID_VARIABLE),
32
+ credential: recordCredentialOf(),
33
+ now: new Date(),
34
+ },
35
+ };
36
+ }
37
+ export async function refuseEmit(request, code, gate, message, globals, caller) {
38
+ const who = caller ?? (await resolveCaller(request).catch(() => null));
39
+ if (who) {
40
+ try {
41
+ const store = await openLocalStore();
42
+ try {
43
+ if (logRefusals(store, [{ gate, verdict: "block", target: request.target, reason: message }], who.refusalContext) > 0) {
44
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
45
+ }
46
+ }
47
+ finally {
48
+ store.close();
49
+ }
50
+ }
51
+ catch {
52
+ }
21
53
  }
22
- let list;
23
- let kind;
24
- if (Array.isArray(parsed))
25
- list = parsed;
26
- else if (isEnvelope(parsed)) {
27
- list = parsed.rows;
28
- kind = parsed.kind;
54
+ if (request.json)
55
+ printJsonError(request.command, { code, message });
56
+ else {
57
+ process.stderr.write(`${request.command}: ${message}\n`);
58
+ process.exitCode = 1;
29
59
  }
30
- else
31
- list = [parsed];
32
- const rows = list.filter((row) => !!row &&
33
- typeof row === "object" &&
34
- !Array.isArray(row) &&
35
- typeof row.anchor === "string" &&
36
- row.anchor.trim() !== "");
37
- return rows.length === list.length ? { rows, kind } : null;
38
- }
39
- function isEnvelope(value) {
40
- if (!value || typeof value !== "object" || Array.isArray(value))
41
- return false;
42
- const candidate = value;
43
- const hasPayload = !!candidate.payload && typeof candidate.payload === "object" && !Array.isArray(candidate.payload);
44
- return Array.isArray(candidate.rows) && typeof candidate.anchor !== "string" && !hasPayload;
45
60
  }
46
- function emitLine(outcome) {
47
- if (outcome.rung === 1)
48
- return `records emit: landed ${outcome.landed} on NuStack (${outcome.projectId})\n`;
49
- if (outcome.rung === 2)
50
- return `records emit: the door refused (${outcome.error}); ${outcome.keys.length} row(s) wait in the outbox\n`;
51
- return `records emit: ${outcome.reason}; ${outcome.keys.length} row(s) kept in the local store\n`;
52
- }
53
- export async function runRecordsEmit(options, globals) {
54
- const json = options.json === true;
55
- const refuse = (message) => {
56
- if (json)
57
- printJsonError("records emit", { code: "VALIDATION_FAILED", message });
58
- else {
59
- process.stderr.write(`records emit: ${message}\n`);
60
- process.exitCode = 1;
61
+ export async function emitRows(request, globals) {
62
+ const caller = await resolveCaller(request);
63
+ const refuse = (code, gate, message) => refuseEmit(request, code, gate, message, globals, caller);
64
+ if (!isEmitKind(request.kind))
65
+ return refuse("VALIDATION_FAILED", "records-unknown-kind", UNKNOWN_KIND_MESSAGE);
66
+ const kind = request.kind;
67
+ let rows = request.rows;
68
+ if (!rows) {
69
+ const parsed = parseRowsText(request.rowText ?? (await readStdin()));
70
+ if (!parsed || parsed.rows.length === 0) {
71
+ return refuse("VALIDATION_FAILED", "records-validation", "the rows must be JSON — one row, an array of rows, or {kind, rows}");
61
72
  }
62
- };
63
- if (!isRecordKind(options.kind))
64
- return refuse("--kind must be backlog, backlog.remove, bookkeeping or gate_event");
65
- const parsed = parseRows(options.row ?? (await readStdin()));
66
- if (!parsed || parsed.rows.length === 0) {
67
- return refuse("the rows must be JSON — one row, an array of rows, or {kind, rows} — each an object with an anchor");
73
+ if (parsed.kind !== undefined && parsed.kind !== kind) {
74
+ return refuse("VALIDATION_FAILED", "records-validation", `the envelope's kind ${JSON.stringify(parsed.kind)} differs from --kind ${kind}`);
75
+ }
76
+ rows = parsed.rows;
68
77
  }
69
- if (parsed.kind !== undefined && parsed.kind !== options.kind) {
70
- return refuse(`the envelope's kind ${JSON.stringify(parsed.kind)} differs from --kind ${options.kind}`);
78
+ const context = {
79
+ projectId: caller.projectId,
80
+ root: caller.root,
81
+ sessionId: caller.refusalContext.sessionId,
82
+ planAnchor: request.plan ?? null,
83
+ settings: readDesktopSettings(),
84
+ credential: caller.refusalContext.credential,
85
+ now: caller.refusalContext.now,
86
+ };
87
+ const writes = [];
88
+ const flags = [];
89
+ for (const [index, row] of rows.entries()) {
90
+ const translated = translateRow(kind, row, index);
91
+ if (!translated.ok)
92
+ return refuse("VALIDATION_FAILED", "records-validation", translated.message);
93
+ const assembled = assembleRecord(translated.kind, translated.input, context, index);
94
+ if (!assembled.ok)
95
+ return refuse("VALIDATION_FAILED", "records-validation", assembled.message);
96
+ writes.push(assembled.write);
97
+ flags.push(...assembled.flags);
71
98
  }
72
- const rows = parsed.rows;
73
- const root = options.root ?? process.cwd();
74
- const resolution = await resolveDoor(root, globals, options.project);
75
99
  const store = await openLocalStore();
100
+ let written;
101
+ let owned = null;
76
102
  try {
77
- const outcomes = [];
78
- for (let start = 0; start < rows.length; start += RECORDS_BATCH_CAP) {
79
- outcomes.push(await emitRecords(store, resolution, options.kind, rows.slice(start, start + RECORDS_BATCH_CAP)));
103
+ try {
104
+ written = writeRecords(store, writes);
105
+ }
106
+ catch (error) {
107
+ if (!(error instanceof AgentOwnedError))
108
+ throw error;
109
+ owned = error;
110
+ written = [];
80
111
  }
81
- if (outcomes.every((outcome) => outcome.rung === 1))
82
- await drainOutbox(store, globals, { dueOnly: true });
83
- const last = outcomes[outcomes.length - 1];
84
- const keys = outcomes.flatMap((outcome) => outcome.keys);
85
- if (json)
86
- printJsonSuccess("records emit", { ...last, keys });
87
- else
88
- process.stdout.write(emitLine({ ...last, keys }));
112
+ if (owned)
113
+ return;
114
+ if (caller.projectId !== null)
115
+ releaseUnboundRows(store, caller.root);
116
+ logRefusals(store, flags.map((flag) => ({
117
+ gate: "secret-scan",
118
+ verdict: "flag",
119
+ target: request.target,
120
+ reason: `redacted a ${flag.patternId} match at ${flag.path}`,
121
+ })), caller.refusalContext);
89
122
  }
90
123
  finally {
91
124
  store.close();
125
+ if (owned)
126
+ await refuse(OBLIGATION_AGENT_OWNED, "agent-owned-obligation", owned.message);
127
+ }
128
+ if (owned)
129
+ return;
130
+ if (flags.length > 0 || written.some((row) => row.syncState === "pending"))
131
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
132
+ const data = {
133
+ written: written.length,
134
+ keys: written.map((row) => row.idempotencyKey),
135
+ local: written.filter((row) => row.syncState === "local").length,
136
+ flags,
137
+ projectId: caller.projectId,
138
+ };
139
+ if (request.json)
140
+ printJsonSuccess(request.command, data);
141
+ else {
142
+ for (const flag of flags)
143
+ process.stderr.write(`${request.command}: redacted a ${flag.patternId} match at ${flag.path}\n`);
144
+ process.stdout.write(`${request.command}: wrote ${data.written} record(s) locally; sending in the background\n`);
145
+ }
146
+ }
147
+ export async function runRecordsEmit(options, globals) {
148
+ await emitRows({
149
+ command: "records emit",
150
+ target: `records emit --kind ${options.kind ?? ""}`.trim(),
151
+ kind: options.kind,
152
+ ...(options.row !== undefined ? { rowText: options.row } : {}),
153
+ ...(options.root !== undefined ? { root: options.root } : {}),
154
+ ...(options.project !== undefined ? { project: options.project } : {}),
155
+ ...(options.session !== undefined ? { session: options.session } : {}),
156
+ ...(options.plan !== undefined ? { plan: options.plan } : {}),
157
+ json: options.json === true,
158
+ }, globals);
159
+ }
160
+ async function drainAs(store, globals, isChild) {
161
+ if (!isChild)
162
+ return drainRecords(store, globals, { isForeground: true });
163
+ if (!acquireDrainLock())
164
+ return null;
165
+ try {
166
+ return await drainRecords(store, globals, { onPass: () => refreshDrainLock() });
167
+ }
168
+ finally {
169
+ releaseDrainLock();
92
170
  }
93
171
  }
94
172
  export async function runRecordsSync(options, globals) {
95
173
  const store = await openLocalStore();
96
174
  try {
97
- const outbox = await drainOutbox(store, globals);
98
- const stored = await drainStore(store, globals);
175
+ const root = process.cwd();
176
+ const project = await resolveProjectId(root, {});
177
+ if (project.ok)
178
+ releaseUnboundRows(store, root);
179
+ const drained = await drainAs(store, globals, isFlagSet(DRAIN_CHILD_VARIABLE));
180
+ if (options.quiet)
181
+ return;
182
+ const counts = await countRecords(store);
99
183
  const result = {
100
- sent: outbox.sent + stored.sent,
101
- waiting: listOutbox(store).length,
102
- stored: listStored(store).length,
103
- lastError: outbox.lastError ?? stored.lastError,
184
+ sent: drained?.sent ?? 0,
185
+ waiting: counts.waitingCount,
186
+ stored: counts.storeCount,
187
+ refused: counts.refusedCount,
188
+ lastError: drained?.lastError ?? counts.lastError,
104
189
  };
105
190
  if (options.json)
106
191
  printJsonSuccess("records sync", result);
107
- else
108
- process.stdout.write(`records sync: sent ${result.sent}; ${result.waiting} wait in the outbox, ${result.stored} kept locally\n`);
192
+ else {
193
+ process.stdout.write(`records sync: sent ${result.sent}; ${result.waiting} waiting, ${result.stored} kept locally, ${result.refused} refused\n`);
194
+ }
109
195
  }
110
196
  finally {
111
197
  store.close();
@@ -114,35 +200,42 @@ export async function runRecordsSync(options, globals) {
114
200
  export async function runRecordsList(options) {
115
201
  const store = await openLocalStore();
116
202
  try {
117
- const outbox = listOutbox(store, options.project);
118
- const stored = listStored(store, options.project ? { projectId: options.project } : {});
119
- const newest = [...outbox].sort((a, b) => b.createdAtMs - a.createdAtMs).find((row) => row.lastError !== null);
203
+ const counts = await countRecords(store, options.project);
204
+ const records = listRecords(store, {
205
+ ...(options.project ? { projectId: options.project } : {}),
206
+ ...(options.kind ? { kind: options.kind } : {}),
207
+ ...(options.plan ? { planAnchor: options.plan } : {}),
208
+ });
120
209
  const data = {
121
- outboxCount: outbox.length,
122
- storeCount: stored.length,
123
- lastError: newest?.lastError ?? null,
124
- outbox,
125
- store: stored,
210
+ outboxCount: counts.outboxCount,
211
+ storeCount: counts.storeCount,
212
+ lastError: counts.lastError,
213
+ pendingCount: counts.pendingCount,
214
+ refusedCount: counts.refusedCount,
215
+ localCount: counts.localCount,
216
+ records,
126
217
  };
127
218
  if (options.json)
128
219
  printJsonSuccess("records list", data);
129
220
  else {
130
- process.stdout.write(`records list: ${data.outboxCount} in the outbox, ${data.storeCount} kept locally\n`);
131
- if (data.lastError)
132
- process.stdout.write(` last error: ${data.lastError}\n`);
221
+ process.stdout.write(`records list: ${counts.pendingCount} pending, ${counts.refusedCount} refused, ${counts.localCount} kept on this device\n`);
222
+ if (counts.lastError)
223
+ process.stdout.write(` last error: ${counts.lastError}\n`);
133
224
  }
134
225
  }
135
226
  finally {
136
227
  store.close();
137
228
  }
138
229
  }
139
- export async function drainBeforeCommand(globals) {
140
- if (!existsSync(stateDbPath()))
141
- return;
230
+ export async function startDrainIfDue(globals) {
142
231
  try {
232
+ if (isFlagSet(DRAIN_CHILD_VARIABLE) || !existsSync(stateDbPath()))
233
+ return;
143
234
  const store = await openLocalStore();
144
235
  try {
145
- await drainOutbox(store, globals, { dueOnly: true, limit: 20 });
236
+ if (hasDueRecord(store.db, Date.now(), readVariable(DEVICE_TOKEN_VARIABLE) !== null)) {
237
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
238
+ }
146
239
  }
147
240
  finally {
148
241
  store.close();
@@ -154,22 +247,27 @@ export async function drainBeforeCommand(globals) {
154
247
  export function registerRecordsCommands(recordsCommand, getGlobalOptions) {
155
248
  recordsCommand
156
249
  .command("emit")
157
- .description("Land records on NuStack, else park them in the outbox, else keep them locally")
158
- .option("--kind <kind>", "backlog, backlog.remove, bookkeeping or gate_event")
250
+ .description("Write records locally and return; a background drain sends them to NuStack")
251
+ .option("--kind <kind>", "obligation, gate_event, changelog, memory, backlog, proposal or triage")
159
252
  .option("--row <json>", "one row, an array of rows, or {kind, rows}, as JSON (default: read from stdin)")
160
253
  .option("--root <dir>", "the folder the records are about (default: the current directory)")
161
254
  .option("--project <id>", "the Project, when the folder carries no binding marker")
255
+ .option("--session <id>", `the session the records belong to (default: $${SESSION_ID_VARIABLE})`)
256
+ .option("--plan <anchor>", "the plan anchor, when a row names none")
162
257
  .option("--json", "emit the versioned machine envelope")
163
258
  .action(async (options) => runRecordsEmit(options, getGlobalOptions()));
164
259
  recordsCommand
165
260
  .command("sync")
166
- .description("Send every outbox row, and stored rows whose folder now has a door")
261
+ .description("Send every due record this device holds to NuStack")
262
+ .option("--quiet", "print nothing")
167
263
  .option("--json", "emit the versioned machine envelope")
168
264
  .action(async (options) => runRecordsSync(options, getGlobalOptions()));
169
265
  recordsCommand
170
266
  .command("list")
171
- .description("Show the rows that have not reached NuStack: the outbox and the local store")
267
+ .description("Show this device's records: what waits, what was refused, what stays local")
172
268
  .option("--project <id>", "only this Project's rows")
269
+ .option("--kind <kind>", "only rows of this kind")
270
+ .option("--plan <anchor>", "only rows of this plan")
173
271
  .option("--json", "emit the versioned machine envelope")
174
272
  .action(async (options) => runRecordsList(options));
175
273
  }
package/dist/index.js CHANGED
@@ -33,7 +33,8 @@ import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
33
33
  import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
34
34
  import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
35
35
  import { registerReviewCommands } from "./commands/review.js";
36
- import { drainBeforeCommand, registerRecordsCommands } from "./commands/records.js";
36
+ import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
37
+ import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
37
38
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
38
39
  import { printJsonError } from "./lib/json_output.js";
39
40
  import { setCliVersion } from "./lib/auth_grant.js";
@@ -237,11 +238,13 @@ registerActivityCommand(project, () => program.opts());
237
238
  registerProjectSyncCommands(project, () => program.opts());
238
239
  registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
239
240
  registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
241
+ registerRecordAliases(program, () => program.opts());
240
242
  program.hook("preAction", async (_program, action) => {
241
243
  if (program.opts().nonInteractive)
242
244
  process.env[NON_INTERACTIVE_VARIABLE] = "1";
243
- if (action.parent?.name() !== "records")
244
- await drainBeforeCommand(program.opts());
245
+ const group = action.parent?.name() ?? "";
246
+ if (group !== "records" && !RECORD_ALIAS_GROUPS.includes(group))
247
+ await startDrainIfDue(program.opts());
245
248
  });
246
249
  const docs = program.command("docs").description("Work with a Project's Documents");
247
250
  docs
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { desktopSettingsPath } from "./local_store/paths.js";
3
+ const DEFAULT_PLACEMENT = "server";
4
+ function placementOf(value) {
5
+ return value === "device" || value === "server" ? value : DEFAULT_PLACEMENT;
6
+ }
7
+ export function readDesktopSettings() {
8
+ const defaults = { placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT } };
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(readFileSync(desktopSettingsPath(), "utf8"));
12
+ }
13
+ catch {
14
+ return defaults;
15
+ }
16
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
17
+ return defaults;
18
+ const file = parsed;
19
+ if (file.schemaVersion !== 1)
20
+ return defaults;
21
+ const placement = file.placement && typeof file.placement === "object" ? file.placement : {};
22
+ return { placement: { memory: placementOf(placement.memory), securityFindings: placementOf(placement.securityFindings) } };
23
+ }
@@ -41,8 +41,8 @@ export async function openLocalStore(options = {}) {
41
41
  const { DatabaseSync } = await loadSqlite();
42
42
  return new DatabaseSync(dbFile);
43
43
  });
44
- db.exec("PRAGMA journal_mode = WAL");
45
44
  db.exec("PRAGMA busy_timeout = 5000");
45
+ db.exec("PRAGMA journal_mode = WAL");
46
46
  db.exec("PRAGMA foreign_keys = ON");
47
47
  migrateToLatest(db);
48
48
  await chmod(dbFile, 0o600).catch(() => { });