@mattstack/rt-client 0.21.0 → 0.24.0
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/client.d.ts +1 -0
- package/dist/commands.d.ts +81 -3
- package/dist/gate-answers.d.ts +10 -0
- package/dist/gate-options.d.ts +24 -0
- package/dist/gate-presentation.d.ts +10 -0
- package/dist/gate.d.ts +9 -0
- package/dist/gate.js +103 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +107 -3
- package/package.json +8 -2
- package/src/client.ts +11 -1
- package/src/commands.ts +63 -11
- package/src/gate-answers.ts +54 -0
- package/src/gate-options.ts +79 -0
- package/src/gate-presentation.ts +17 -0
- package/src/gate.ts +9 -0
- package/src/index.ts +6 -0
package/dist/client.d.ts
CHANGED
|
@@ -187,6 +187,7 @@ export declare function paneFocus(a: Commands["pane:focus"]["payload"], o?: RtCl
|
|
|
187
187
|
export declare function reconcilerStatus(o?: RtClientOptions): Promise<RtResponse<Commands["reconciler:status"]["data"]>>;
|
|
188
188
|
export declare function reconcilerClear(a: Commands["reconciler:clear"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["reconciler:clear"]["data"]>>;
|
|
189
189
|
export declare function gateOpen(a: Commands["gate:open"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["gate:open"]["data"]>>;
|
|
190
|
+
export declare function gateAsk(a: Commands["gate:ask"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["gate:ask"]["data"]>>;
|
|
190
191
|
export declare function gateAnswer(a: Commands["gate:answer"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["gate:answer"]["data"]>>;
|
|
191
192
|
/** Daemon clamps its own wait to 240s (gates-store.ts); the client abort
|
|
192
193
|
must outlive that cap, same +10s buffer as commands/events.ts's
|
package/dist/commands.d.ts
CHANGED
|
@@ -107,6 +107,7 @@ export type ExecutorState = "live" | "blocked" | "hidden" | "gone" | "cleared" |
|
|
|
107
107
|
export type GateOption = string | {
|
|
108
108
|
value: string;
|
|
109
109
|
label: string;
|
|
110
|
+
recommended?: boolean;
|
|
110
111
|
};
|
|
111
112
|
export interface GateOrigin {
|
|
112
113
|
paneId?: string;
|
|
@@ -122,8 +123,15 @@ export interface GateQuestion {
|
|
|
122
123
|
multi: boolean;
|
|
123
124
|
options: GateOption[];
|
|
124
125
|
}
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
/** Implementations live in gate-options.ts (the browser-safe ./gate
|
|
127
|
+
subpath); re-exported here so existing commands.ts/index.ts consumers
|
|
128
|
+
are unaffected. */
|
|
129
|
+
export { gateOptionValue, gateOptionLabel } from "./gate-options.ts";
|
|
130
|
+
/** `session` is the answering surface's own session id, recorded so the
|
|
131
|
+
push facility can tell a self-answer from a remote one and skip the
|
|
132
|
+
doorbell it would otherwise send back to the writer. Optional: a caller
|
|
133
|
+
that supplies none (the board status-bin answers `by: "pane"` with no
|
|
134
|
+
session) still matches self by `by === GATE_BY_PANE`. */
|
|
127
135
|
export interface GateAnswer {
|
|
128
136
|
answers: Record<string, string | string[] | {
|
|
129
137
|
value: string | string[];
|
|
@@ -132,6 +140,7 @@ export interface GateAnswer {
|
|
|
132
140
|
by: string;
|
|
133
141
|
answeredAt: number;
|
|
134
142
|
overridden?: boolean;
|
|
143
|
+
session?: string;
|
|
135
144
|
}
|
|
136
145
|
export interface GateRow {
|
|
137
146
|
id: string;
|
|
@@ -162,6 +171,15 @@ export interface GateRow {
|
|
|
162
171
|
at: number;
|
|
163
172
|
} | null;
|
|
164
173
|
released: boolean;
|
|
174
|
+
/** Set once the nudged pane has provably read the answer: either it
|
|
175
|
+
self-answered (stamped in the same transaction as the answer) or a
|
|
176
|
+
later `markConsumed` call recorded that it acted on a push. `null`
|
|
177
|
+
until then, so a sweep can tell an answered-but-unread row from a
|
|
178
|
+
settled one. Currently only stamped for herd-subject gates (the
|
|
179
|
+
self-answer path and `rt herd answer`'s nudged-session read); a
|
|
180
|
+
non-herd gate with a nudge stays `null` even after its pane
|
|
181
|
+
reconciles. */
|
|
182
|
+
consumedAt: number | null;
|
|
165
183
|
owner: string | null;
|
|
166
184
|
escalatedAt: number | null;
|
|
167
185
|
/** Set by answer-time execution handling: an answered gate whose executor
|
|
@@ -217,7 +235,7 @@ export interface HerdJobInfo {
|
|
|
217
235
|
createdAt: number;
|
|
218
236
|
updatedAt: number;
|
|
219
237
|
}
|
|
220
|
-
/** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. */
|
|
238
|
+
/** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
|
|
221
239
|
export interface HerdStatusData {
|
|
222
240
|
herd: HerdInfo;
|
|
223
241
|
jobs: Array<HerdJobInfo & {
|
|
@@ -225,6 +243,7 @@ export interface HerdStatusData {
|
|
|
225
243
|
paneStatus: string | null;
|
|
226
244
|
lastGateStatus: GateStatus | null;
|
|
227
245
|
lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null;
|
|
246
|
+
lastGateConsumed: boolean | null;
|
|
228
247
|
}>;
|
|
229
248
|
unread: number;
|
|
230
249
|
lifecycleConnected: boolean;
|
|
@@ -1330,6 +1349,29 @@ export interface Commands {
|
|
|
1330
1349
|
};
|
|
1331
1350
|
data: DiscussionsDiffsData;
|
|
1332
1351
|
};
|
|
1352
|
+
/** Positioned inline MR comment with server-side DiffNote verification:
|
|
1353
|
+
posts, re-checks the created note's type from the creation response,
|
|
1354
|
+
and on the silent general-note degrade deletes the stray note and
|
|
1355
|
+
retries ONCE with freshly fetched diff_refs. The retry repairs only
|
|
1356
|
+
the stale-diff-refs degrade; a caller-supplied position GitLab
|
|
1357
|
+
rejects stays rejected, and after a second degrade both stray notes
|
|
1358
|
+
are deleted and the call fails. `verified: true` means the check ran. */
|
|
1359
|
+
"mr:comment-inline": {
|
|
1360
|
+
payload: {
|
|
1361
|
+
repoName: string;
|
|
1362
|
+
iid: number;
|
|
1363
|
+
body: string;
|
|
1364
|
+
path: string;
|
|
1365
|
+
line: number;
|
|
1366
|
+
oldPath?: string;
|
|
1367
|
+
oldLine?: number;
|
|
1368
|
+
};
|
|
1369
|
+
data: {
|
|
1370
|
+
discussionId: string;
|
|
1371
|
+
noteId: number;
|
|
1372
|
+
verified: true;
|
|
1373
|
+
};
|
|
1374
|
+
};
|
|
1333
1375
|
/** Wire reply is `{ok:true}` on success (no `data`); a failure is `{ok:false,error}`. */
|
|
1334
1376
|
"mr:action": {
|
|
1335
1377
|
payload: {
|
|
@@ -1433,6 +1475,41 @@ export interface Commands {
|
|
|
1433
1475
|
supersededId: string | null;
|
|
1434
1476
|
};
|
|
1435
1477
|
};
|
|
1478
|
+
/** Ceremony layer over gate:open: resolves subject from the caller's
|
|
1479
|
+
session (an explicit subject always wins as the subject, but still
|
|
1480
|
+
picks up run linkage from the session's own running run; else its
|
|
1481
|
+
single running run as `run:<id>`; else its agent record's recorded
|
|
1482
|
+
subject, or `agent:<id>` when it has none), computes
|
|
1483
|
+
presentation via gatePresentation, supplies nudge/origin, and omits an
|
|
1484
|
+
oversized context instead of rejecting it. `meta` and `agent` forward
|
|
1485
|
+
to gate:open verbatim; `origin` passthrough fields fill gaps in the
|
|
1486
|
+
ceremony's own origin (presentation, paneId, runId, run-derived
|
|
1487
|
+
worktree always win over a caller-supplied value for the same key).
|
|
1488
|
+
Delegates to gate:open for everything else (validation, supersede,
|
|
1489
|
+
events, push). */
|
|
1490
|
+
"gate:ask": {
|
|
1491
|
+
payload: {
|
|
1492
|
+
questions: GateQuestion[];
|
|
1493
|
+
context?: string;
|
|
1494
|
+
kind?: string;
|
|
1495
|
+
subject?: string;
|
|
1496
|
+
sessionId?: string;
|
|
1497
|
+
paneId?: string;
|
|
1498
|
+
meta?: Record<string, unknown>;
|
|
1499
|
+
agent?: string;
|
|
1500
|
+
origin?: {
|
|
1501
|
+
surface?: string;
|
|
1502
|
+
tabId?: string;
|
|
1503
|
+
worktree?: string;
|
|
1504
|
+
};
|
|
1505
|
+
};
|
|
1506
|
+
data: {
|
|
1507
|
+
id: string;
|
|
1508
|
+
presentation: "form" | "wait";
|
|
1509
|
+
subject: string;
|
|
1510
|
+
supersededId: string | null;
|
|
1511
|
+
};
|
|
1512
|
+
};
|
|
1436
1513
|
/**
|
|
1437
1514
|
* A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
|
|
1438
1515
|
* `conflict:true` and the WINNING row, so every consumer gets the winner
|
|
@@ -1653,6 +1730,7 @@ export interface Commands {
|
|
|
1653
1730
|
"herd:answer": {
|
|
1654
1731
|
payload: {
|
|
1655
1732
|
gate: string;
|
|
1733
|
+
sessionId?: string;
|
|
1656
1734
|
};
|
|
1657
1735
|
data: {
|
|
1658
1736
|
gate: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GateQuestion, GateAnswer } from "./commands.ts";
|
|
2
|
+
export type GateAnswerWire = GateAnswer["answers"][string];
|
|
3
|
+
/** Both wire shapes carry the same value underneath: bare, or {value, note?}
|
|
4
|
+
when a panel attaches free text. Validation reads only the value. */
|
|
5
|
+
export declare function unwrapGateAnswerValue(raw: unknown): unknown;
|
|
6
|
+
/** Option membership is required whenever a question declares options,
|
|
7
|
+
checked against the unwrapped value (every element, for multi); an
|
|
8
|
+
empty options array stays free-form. Every question id must appear as
|
|
9
|
+
an answers key. Error strings are a wire contract; packages/rt-client/test/gate-answers.test.ts pins them verbatim. */
|
|
10
|
+
export declare function validateGateAnswers(questions: GateQuestion[], answers: Record<string, unknown>): string | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GateOption, GateQuestion } from "./commands.ts";
|
|
2
|
+
/** The canonical stored/emitted option shape (contract C11). GateOption
|
|
3
|
+
(the input union) is unchanged; rows normalized by the daemon always
|
|
4
|
+
satisfy this. */
|
|
5
|
+
export interface GateOptionObject {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function gateOptionValue(o: GateOption): string;
|
|
10
|
+
export declare function gateOptionLabel(o: GateOption): string;
|
|
11
|
+
/** Bare string s becomes {value: s, label: s}; a well-formed {value,label}
|
|
12
|
+
object passes through untouched. Total over whatever actually arrives
|
|
13
|
+
on the wire, not just the declared GateOption union: a partial object
|
|
14
|
+
fills the missing field from the one present, and anything else
|
|
15
|
+
(null, a number, an object with neither field) is coerced via String()
|
|
16
|
+
into both fields. The resulting label is then capitalized when
|
|
17
|
+
word-like (see isWordLikeLabel), and an object form's `recommended:
|
|
18
|
+
true` lifts into a " (Recommended)" label suffix -- guarded against
|
|
19
|
+
double-appending -- since `recommended` itself does not survive into
|
|
20
|
+
the returned object; the suffix IS its wire representation. Every
|
|
21
|
+
returned entry is a full {value,label} pair -- callers may trust the
|
|
22
|
+
return type without re-checking it. Pure and order-preserving. */
|
|
23
|
+
export declare function normalizeGateOptions(options: GateOption[]): GateOptionObject[];
|
|
24
|
+
export declare function normalizeGateQuestions(questions: GateQuestion[]): GateQuestion[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GateQuestion } from "./commands.ts";
|
|
2
|
+
export declare const GATE_FORM_OPTION_CAP = 4;
|
|
3
|
+
/** The ONE presentation rule (spec Phase 1): form iff an injectable pane
|
|
4
|
+
exists, a nudge target exists, and every question fits the native form's
|
|
5
|
+
per-question option cap. */
|
|
6
|
+
export declare function gatePresentation(args: {
|
|
7
|
+
paneId?: string | undefined;
|
|
8
|
+
sessionId?: string | undefined;
|
|
9
|
+
questions: GateQuestion[];
|
|
10
|
+
}): "form" | "wait";
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe entry point (the "./gate" subpath export): the pure gate
|
|
3
|
+
* helpers only, so a browser bundle (mattstack-apps gate-kit) never drags in
|
|
4
|
+
* commands.ts's Node-only neighbors the way importing from "." would.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./gate-answers.ts";
|
|
7
|
+
export * from "./gate-options.ts";
|
|
8
|
+
export * from "./gate-presentation.ts";
|
|
9
|
+
export type { GateOption, GateQuestion, GateAnswer } from "./commands.ts";
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/gate-options.ts
|
|
2
|
+
function gateOptionValue(o) {
|
|
3
|
+
return typeof o === "string" ? o : o.value;
|
|
4
|
+
}
|
|
5
|
+
function gateOptionLabel(o) {
|
|
6
|
+
return typeof o === "string" ? o : o.label || o.value;
|
|
7
|
+
}
|
|
8
|
+
var RECOMMENDED_SUFFIX = " (Recommended)";
|
|
9
|
+
var HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
|
|
10
|
+
function isWordLikeLabel(label) {
|
|
11
|
+
return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
|
|
12
|
+
}
|
|
13
|
+
function capitalize(label) {
|
|
14
|
+
return isWordLikeLabel(label) ? label[0].toUpperCase() + label.slice(1) : label;
|
|
15
|
+
}
|
|
16
|
+
function normalizeGateOptions(options) {
|
|
17
|
+
return options.map((o) => {
|
|
18
|
+
const recommended = o !== null && typeof o === "object" && o.recommended === true;
|
|
19
|
+
let value;
|
|
20
|
+
let label;
|
|
21
|
+
if (typeof o === "string") {
|
|
22
|
+
value = o;
|
|
23
|
+
label = o;
|
|
24
|
+
} else if (o !== null && typeof o === "object") {
|
|
25
|
+
const v = typeof o.value === "string" ? o.value : undefined;
|
|
26
|
+
const l = typeof o.label === "string" ? o.label : undefined;
|
|
27
|
+
value = v ?? l ?? String(o);
|
|
28
|
+
label = l ?? v ?? String(o);
|
|
29
|
+
} else {
|
|
30
|
+
value = String(o);
|
|
31
|
+
label = String(o);
|
|
32
|
+
}
|
|
33
|
+
label = capitalize(label);
|
|
34
|
+
if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label))
|
|
35
|
+
label += RECOMMENDED_SUFFIX;
|
|
36
|
+
return { value, label };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function normalizeGateQuestions(questions) {
|
|
40
|
+
return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/gate-answers.ts
|
|
44
|
+
function unwrapGateAnswerValue(raw) {
|
|
45
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
|
|
46
|
+
return raw.value;
|
|
47
|
+
}
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
function wrapperNoteIsValid(raw) {
|
|
51
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("value" in raw)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const note = raw.note;
|
|
55
|
+
return note === undefined || typeof note === "string";
|
|
56
|
+
}
|
|
57
|
+
function validateGateAnswers(questions, answers) {
|
|
58
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
59
|
+
for (const [qid, raw] of Object.entries(answers)) {
|
|
60
|
+
const question = byId.get(qid);
|
|
61
|
+
if (!question)
|
|
62
|
+
return `unknown question id: ${qid}`;
|
|
63
|
+
if (!wrapperNoteIsValid(raw))
|
|
64
|
+
return `question ${qid} note must be a string`;
|
|
65
|
+
const value = unwrapGateAnswerValue(raw);
|
|
66
|
+
const isArray = Array.isArray(value);
|
|
67
|
+
if (question.multi && !isArray)
|
|
68
|
+
return `question ${qid} expects an array (multi)`;
|
|
69
|
+
if (!question.multi && isArray)
|
|
70
|
+
return `question ${qid} expects a single value`;
|
|
71
|
+
const values = isArray ? value : [value];
|
|
72
|
+
if (!values.every((v) => typeof v === "string"))
|
|
73
|
+
return `question ${qid} value must be a string`;
|
|
74
|
+
if (question.options.length > 0) {
|
|
75
|
+
const members = question.options.map(gateOptionValue);
|
|
76
|
+
for (const v of values) {
|
|
77
|
+
if (!members.includes(v))
|
|
78
|
+
return `answer for "${qid}" is not one of its options: "${v}"`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const missing = questions.map((q) => q.id).filter((id) => !Object.prototype.hasOwnProperty.call(answers, id));
|
|
83
|
+
if (missing.length > 0)
|
|
84
|
+
return `missing answer(s) for: ${missing.join(", ")}`;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
// src/gate-presentation.ts
|
|
88
|
+
var GATE_FORM_OPTION_CAP = 4;
|
|
89
|
+
function gatePresentation(args) {
|
|
90
|
+
if (!args.paneId || !args.sessionId)
|
|
91
|
+
return "wait";
|
|
92
|
+
return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP) ? "form" : "wait";
|
|
93
|
+
}
|
|
94
|
+
export {
|
|
95
|
+
validateGateAnswers,
|
|
96
|
+
unwrapGateAnswerValue,
|
|
97
|
+
normalizeGateQuestions,
|
|
98
|
+
normalizeGateOptions,
|
|
99
|
+
gatePresentation,
|
|
100
|
+
gateOptionValue,
|
|
101
|
+
gateOptionLabel,
|
|
102
|
+
GATE_FORM_OPTION_CAP
|
|
103
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
|
|
2
2
|
export type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
3
|
-
export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatAck, chatClaim, chatRelease, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatDm, chatArchive, chatDmOpen, eventsHead, eventsEmit, eventsWait, eventsList, agentStart, agentResume, agentGet, agentList, paneList, panePeek, paneSpawn, paneAccounts, paneDirectories, chatInvite, paneSend, paneFocus, reconcilerStatus, reconcilerClear, gateOpen, gateAnswer, gateWait, gateList, gatePark, gateClose, gateSubscribe, gateUnsubscribe, gateSubscriptions, herdStart, herdSpawn, herdAsk, herdMilestone, herdAnswer, herdReport, herdGates, herdStatus, herdList, herdResume, herdClose, herdAttend, herdWrapUp, herdStopHidden, bgEnsure, bgStatus, bgStop, bgRelease, } from "./client.ts";
|
|
3
|
+
export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatAck, chatClaim, chatRelease, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatDm, chatArchive, chatDmOpen, eventsHead, eventsEmit, eventsWait, eventsList, agentStart, agentResume, agentGet, agentList, paneList, panePeek, paneSpawn, paneAccounts, paneDirectories, chatInvite, paneSend, paneFocus, reconcilerStatus, reconcilerClear, gateOpen, gateAsk, gateAnswer, gateWait, gateList, gatePark, gateClose, gateSubscribe, gateUnsubscribe, gateSubscriptions, herdStart, herdSpawn, herdAsk, herdMilestone, herdAnswer, herdReport, herdGates, herdStatus, herdList, herdResume, herdClose, herdAttend, herdWrapUp, herdStopHidden, bgEnsure, bgStatus, bgStop, bgRelease, } from "./client.ts";
|
|
4
4
|
export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
|
|
5
|
+
export { GATE_FORM_OPTION_CAP, gatePresentation } from "./gate-presentation.ts";
|
|
6
|
+
export { normalizeGateOptions, normalizeGateQuestions } from "./gate-options.ts";
|
|
7
|
+
export type { GateOptionObject } from "./gate-options.ts";
|
|
8
|
+
export { unwrapGateAnswerValue, validateGateAnswers } from "./gate-answers.ts";
|
|
9
|
+
export type { GateAnswerWire } from "./gate-answers.ts";
|
|
5
10
|
export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, BranchEnrichment, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, ChatClaimOutcome, RoomSummary, BuddyStatus, PresenceRow, AgentRecord, AgentSurface, AgentStatus, ExecutorState, ExecutorView, ReconcilerStatus, ChatPane, PaneAccount, PaneDirectory, InviteResult, PaneDelivery, PaneSendResult, PaneFocusResult, GateStatus, GateOption, GateOrigin, GateQuestion, GateAnswer, GateRow, GateSubscription, HerdInfo, HerdListRow, HerdJobInfo, HerdStatusData, } from "./commands.ts";
|
|
6
11
|
export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
|
|
7
12
|
export type { RelayEventType } from "./relay.ts";
|
package/dist/index.js
CHANGED
|
@@ -288,6 +288,13 @@ function gateOpen(a, o = {}) {
|
|
|
288
288
|
payload[k] = a[k];
|
|
289
289
|
return rtCommand("gate:open", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
|
|
290
290
|
}
|
|
291
|
+
function gateAsk(a, o = {}) {
|
|
292
|
+
const payload = { questions: a.questions };
|
|
293
|
+
for (const k of ["context", "kind", "subject", "sessionId", "paneId", "meta", "agent", "origin"])
|
|
294
|
+
if (a[k] !== undefined)
|
|
295
|
+
payload[k] = a[k];
|
|
296
|
+
return rtCommand("gate:ask", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
|
|
297
|
+
}
|
|
291
298
|
function gateAnswer(a, o = {}) {
|
|
292
299
|
const payload = { id: a.id, answers: a.answers, by: a.by };
|
|
293
300
|
for (const k of ["session", "override"])
|
|
@@ -359,7 +366,8 @@ function herdMilestone(a, o = {}) {
|
|
|
359
366
|
return rtCommand("herd:milestone", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
|
|
360
367
|
}
|
|
361
368
|
function herdAnswer(a, o = {}) {
|
|
362
|
-
|
|
369
|
+
const payload = { gate: a.gate, ...a.sessionId !== undefined ? { sessionId: a.sessionId } : {} };
|
|
370
|
+
return rtCommand("herd:answer", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
|
|
363
371
|
}
|
|
364
372
|
function herdReport(a, o = {}) {
|
|
365
373
|
return rtCommand("herd:report", { herd: a.herd, job: a.job, body: a.body }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 30000 });
|
|
@@ -410,14 +418,50 @@ function bgStop(o = {}) {
|
|
|
410
418
|
function bgRelease(a, o = {}) {
|
|
411
419
|
return rtCommand("bg:release", { claim: a.claim }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
|
|
412
420
|
}
|
|
413
|
-
// src/
|
|
414
|
-
var GATE_BY_PANE = "pane";
|
|
421
|
+
// src/gate-options.ts
|
|
415
422
|
function gateOptionValue(o) {
|
|
416
423
|
return typeof o === "string" ? o : o.value;
|
|
417
424
|
}
|
|
418
425
|
function gateOptionLabel(o) {
|
|
419
426
|
return typeof o === "string" ? o : o.label || o.value;
|
|
420
427
|
}
|
|
428
|
+
var RECOMMENDED_SUFFIX = " (Recommended)";
|
|
429
|
+
var HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
|
|
430
|
+
function isWordLikeLabel(label) {
|
|
431
|
+
return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
|
|
432
|
+
}
|
|
433
|
+
function capitalize(label) {
|
|
434
|
+
return isWordLikeLabel(label) ? label[0].toUpperCase() + label.slice(1) : label;
|
|
435
|
+
}
|
|
436
|
+
function normalizeGateOptions(options) {
|
|
437
|
+
return options.map((o) => {
|
|
438
|
+
const recommended = o !== null && typeof o === "object" && o.recommended === true;
|
|
439
|
+
let value;
|
|
440
|
+
let label;
|
|
441
|
+
if (typeof o === "string") {
|
|
442
|
+
value = o;
|
|
443
|
+
label = o;
|
|
444
|
+
} else if (o !== null && typeof o === "object") {
|
|
445
|
+
const v = typeof o.value === "string" ? o.value : undefined;
|
|
446
|
+
const l = typeof o.label === "string" ? o.label : undefined;
|
|
447
|
+
value = v ?? l ?? String(o);
|
|
448
|
+
label = l ?? v ?? String(o);
|
|
449
|
+
} else {
|
|
450
|
+
value = String(o);
|
|
451
|
+
label = String(o);
|
|
452
|
+
}
|
|
453
|
+
label = capitalize(label);
|
|
454
|
+
if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label))
|
|
455
|
+
label += RECOMMENDED_SUFFIX;
|
|
456
|
+
return { value, label };
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function normalizeGateQuestions(questions) {
|
|
460
|
+
return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/commands.ts
|
|
464
|
+
var GATE_BY_PANE = "pane";
|
|
421
465
|
var COMMAND_NAMES = [
|
|
422
466
|
"project-mrs:read",
|
|
423
467
|
"discussions:read",
|
|
@@ -477,6 +521,7 @@ var COMMAND_NAMES = [
|
|
|
477
521
|
"discussions:resolve",
|
|
478
522
|
"discussions:reply",
|
|
479
523
|
"discussions:diffs",
|
|
524
|
+
"mr:comment-inline",
|
|
480
525
|
"mr:action",
|
|
481
526
|
"mr:fetch-job-detail",
|
|
482
527
|
"mr:fetch-job-trace",
|
|
@@ -489,6 +534,7 @@ var COMMAND_NAMES = [
|
|
|
489
534
|
"reconciler:status",
|
|
490
535
|
"reconciler:clear",
|
|
491
536
|
"gate:open",
|
|
537
|
+
"gate:ask",
|
|
492
538
|
"gate:answer",
|
|
493
539
|
"gate:wait",
|
|
494
540
|
"gate:list",
|
|
@@ -530,6 +576,57 @@ var COMMAND_NAMES = [
|
|
|
530
576
|
"bg:stop",
|
|
531
577
|
"bg:release"
|
|
532
578
|
];
|
|
579
|
+
// src/gate-presentation.ts
|
|
580
|
+
var GATE_FORM_OPTION_CAP = 4;
|
|
581
|
+
function gatePresentation(args) {
|
|
582
|
+
if (!args.paneId || !args.sessionId)
|
|
583
|
+
return "wait";
|
|
584
|
+
return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP) ? "form" : "wait";
|
|
585
|
+
}
|
|
586
|
+
// src/gate-answers.ts
|
|
587
|
+
function unwrapGateAnswerValue(raw) {
|
|
588
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
|
|
589
|
+
return raw.value;
|
|
590
|
+
}
|
|
591
|
+
return raw;
|
|
592
|
+
}
|
|
593
|
+
function wrapperNoteIsValid(raw) {
|
|
594
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("value" in raw)) {
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
const note = raw.note;
|
|
598
|
+
return note === undefined || typeof note === "string";
|
|
599
|
+
}
|
|
600
|
+
function validateGateAnswers(questions, answers) {
|
|
601
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
602
|
+
for (const [qid, raw] of Object.entries(answers)) {
|
|
603
|
+
const question = byId.get(qid);
|
|
604
|
+
if (!question)
|
|
605
|
+
return `unknown question id: ${qid}`;
|
|
606
|
+
if (!wrapperNoteIsValid(raw))
|
|
607
|
+
return `question ${qid} note must be a string`;
|
|
608
|
+
const value = unwrapGateAnswerValue(raw);
|
|
609
|
+
const isArray = Array.isArray(value);
|
|
610
|
+
if (question.multi && !isArray)
|
|
611
|
+
return `question ${qid} expects an array (multi)`;
|
|
612
|
+
if (!question.multi && isArray)
|
|
613
|
+
return `question ${qid} expects a single value`;
|
|
614
|
+
const values = isArray ? value : [value];
|
|
615
|
+
if (!values.every((v) => typeof v === "string"))
|
|
616
|
+
return `question ${qid} value must be a string`;
|
|
617
|
+
if (question.options.length > 0) {
|
|
618
|
+
const members = question.options.map(gateOptionValue);
|
|
619
|
+
for (const v of values) {
|
|
620
|
+
if (!members.includes(v))
|
|
621
|
+
return `answer for "${qid}" is not one of its options: "${v}"`;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const missing = questions.map((q) => q.id).filter((id) => !Object.prototype.hasOwnProperty.call(answers, id));
|
|
626
|
+
if (missing.length > 0)
|
|
627
|
+
return `missing answer(s) for: ${missing.join(", ")}`;
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
533
630
|
// src/relay.ts
|
|
534
631
|
var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
|
|
535
632
|
function subscribe(onEvent, opts = {}) {
|
|
@@ -2262,6 +2359,8 @@ async function resolveNameToIdentity(name, reposJsonPath) {
|
|
|
2262
2359
|
}
|
|
2263
2360
|
export {
|
|
2264
2361
|
validateValue,
|
|
2362
|
+
validateGateAnswers,
|
|
2363
|
+
unwrapGateAnswerValue,
|
|
2265
2364
|
unsetSetting,
|
|
2266
2365
|
subscribe,
|
|
2267
2366
|
setSettingsWarnSink,
|
|
@@ -2289,6 +2388,8 @@ export {
|
|
|
2289
2388
|
paneAccounts,
|
|
2290
2389
|
openSmartPane,
|
|
2291
2390
|
normalizeRemote,
|
|
2391
|
+
normalizeGateQuestions,
|
|
2392
|
+
normalizeGateOptions,
|
|
2292
2393
|
listTeams,
|
|
2293
2394
|
listSettings,
|
|
2294
2395
|
listRuns,
|
|
@@ -2315,12 +2416,14 @@ export {
|
|
|
2315
2416
|
gateUnsubscribe,
|
|
2316
2417
|
gateSubscriptions,
|
|
2317
2418
|
gateSubscribe,
|
|
2419
|
+
gatePresentation,
|
|
2318
2420
|
gatePark,
|
|
2319
2421
|
gateOptionValue,
|
|
2320
2422
|
gateOptionLabel,
|
|
2321
2423
|
gateOpen,
|
|
2322
2424
|
gateList,
|
|
2323
2425
|
gateClose,
|
|
2426
|
+
gateAsk,
|
|
2324
2427
|
gateAnswer,
|
|
2325
2428
|
formatPaneRef,
|
|
2326
2429
|
explainSetting,
|
|
@@ -2366,6 +2469,7 @@ export {
|
|
|
2366
2469
|
abandonRun,
|
|
2367
2470
|
SCOPE_ORDER,
|
|
2368
2471
|
REGISTRY,
|
|
2472
|
+
GATE_FORM_OPTION_CAP,
|
|
2369
2473
|
GATE_BY_PANE,
|
|
2370
2474
|
DEFAULT_WS_URL,
|
|
2371
2475
|
DEFAULT_SOCK,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mattstack/rt-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
"import": "./dist/settings/identity-codec.js",
|
|
16
16
|
"default": "./dist/settings/identity-codec.js"
|
|
17
17
|
},
|
|
18
|
+
"./gate": {
|
|
19
|
+
"types": "./dist/gate.d.ts",
|
|
20
|
+
"bun": "./src/gate.ts",
|
|
21
|
+
"import": "./dist/gate.js",
|
|
22
|
+
"default": "./dist/gate.js"
|
|
23
|
+
},
|
|
18
24
|
"./test/fake-daemon.ts": "./test/fake-daemon.ts"
|
|
19
25
|
},
|
|
20
26
|
"peerDependencies": {
|
|
@@ -45,7 +51,7 @@
|
|
|
45
51
|
"bun": ">=1.0.0"
|
|
46
52
|
},
|
|
47
53
|
"scripts": {
|
|
48
|
-
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && tsc -p tsconfig.json",
|
|
54
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && bun build src/gate.ts --outfile dist/gate.js --target browser --format esm && tsc -p tsconfig.json",
|
|
49
55
|
"check-types": "tsc --noEmit -p tsconfig.json",
|
|
50
56
|
"prepack": "bun run build"
|
|
51
57
|
},
|
package/src/client.ts
CHANGED
|
@@ -472,6 +472,15 @@ export function gateOpen(
|
|
|
472
472
|
return rtCommand<Commands["gate:open"]["data"]>("gate:open", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
export function gateAsk(
|
|
476
|
+
a: Commands["gate:ask"]["payload"],
|
|
477
|
+
o: RtClientOptions = {},
|
|
478
|
+
): Promise<RtResponse<Commands["gate:ask"]["data"]>> {
|
|
479
|
+
const payload: Record<string, unknown> = { questions: a.questions };
|
|
480
|
+
for (const k of ["context", "kind", "subject", "sessionId", "paneId", "meta", "agent", "origin"] as const) if (a[k] !== undefined) payload[k] = a[k];
|
|
481
|
+
return rtCommand<Commands["gate:ask"]["data"]>("gate:ask", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
|
|
482
|
+
}
|
|
483
|
+
|
|
475
484
|
export function gateAnswer(
|
|
476
485
|
a: Commands["gate:answer"]["payload"],
|
|
477
486
|
o: RtClientOptions = {},
|
|
@@ -586,7 +595,8 @@ export function herdAnswer(
|
|
|
586
595
|
a: Commands["herd:answer"]["payload"],
|
|
587
596
|
o: RtClientOptions = {},
|
|
588
597
|
): Promise<RtResponse<Commands["herd:answer"]["data"]>> {
|
|
589
|
-
|
|
598
|
+
const payload = { gate: a.gate, ...(a.sessionId !== undefined ? { sessionId: a.sessionId } : {}) };
|
|
599
|
+
return rtCommand<Commands["herd:answer"]["data"]>("herd:answer", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
|
|
590
600
|
}
|
|
591
601
|
|
|
592
602
|
/** A disposable job's report also closes its pane, one herdr CLI call under the runner's own 15s budget. */
|
package/src/commands.ts
CHANGED
|
@@ -97,7 +97,7 @@ export const GATE_BY_PANE = "pane";
|
|
|
97
97
|
export type GateStatus = "open" | "answered" | "parked" | "closed";
|
|
98
98
|
/** Reconciler's view of an agent's liveness; also the value `GateRow.executor` is stamped with. */
|
|
99
99
|
export type ExecutorState = "live" | "blocked" | "hidden" | "gone" | "cleared" | "unknown";
|
|
100
|
-
export type GateOption = string | { value: string; label: string };
|
|
100
|
+
export type GateOption = string | { value: string; label: string; recommended?: boolean };
|
|
101
101
|
export interface GateOrigin {
|
|
102
102
|
paneId?: string;
|
|
103
103
|
tabId?: string;
|
|
@@ -107,13 +107,16 @@ export interface GateOrigin {
|
|
|
107
107
|
presentation?: "form" | "wait";
|
|
108
108
|
}
|
|
109
109
|
export interface GateQuestion { id: string; label: string; multi: boolean; options: GateOption[] }
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
export
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
/** Implementations live in gate-options.ts (the browser-safe ./gate
|
|
111
|
+
subpath); re-exported here so existing commands.ts/index.ts consumers
|
|
112
|
+
are unaffected. */
|
|
113
|
+
export { gateOptionValue, gateOptionLabel } from "./gate-options.ts";
|
|
114
|
+
/** `session` is the answering surface's own session id, recorded so the
|
|
115
|
+
push facility can tell a self-answer from a remote one and skip the
|
|
116
|
+
doorbell it would otherwise send back to the writer. Optional: a caller
|
|
117
|
+
that supplies none (the board status-bin answers `by: "pane"` with no
|
|
118
|
+
session) still matches self by `by === GATE_BY_PANE`. */
|
|
119
|
+
export interface GateAnswer { answers: Record<string, string | string[] | { value: string | string[]; note?: string }>; by: string; answeredAt: number; overridden?: boolean; session?: string }
|
|
117
120
|
export interface GateRow {
|
|
118
121
|
id: string; subject: string; kind: string;
|
|
119
122
|
questions: GateQuestion[]; meta: Record<string, unknown> | null;
|
|
@@ -131,6 +134,15 @@ export interface GateRow {
|
|
|
131
134
|
nudge: { session: string } | null;
|
|
132
135
|
delivery: { outcome: "delivered" | "dead-pane" | "confirmed" | "stuck"; at: number } | null;
|
|
133
136
|
released: boolean;
|
|
137
|
+
/** Set once the nudged pane has provably read the answer: either it
|
|
138
|
+
self-answered (stamped in the same transaction as the answer) or a
|
|
139
|
+
later `markConsumed` call recorded that it acted on a push. `null`
|
|
140
|
+
until then, so a sweep can tell an answered-but-unread row from a
|
|
141
|
+
settled one. Currently only stamped for herd-subject gates (the
|
|
142
|
+
self-answer path and `rt herd answer`'s nudged-session read); a
|
|
143
|
+
non-herd gate with a nudge stays `null` even after its pane
|
|
144
|
+
reconciles. */
|
|
145
|
+
consumedAt: number | null;
|
|
134
146
|
owner: string | null;
|
|
135
147
|
escalatedAt: number | null;
|
|
136
148
|
/** Set by answer-time execution handling: an answered gate whose executor
|
|
@@ -155,10 +167,10 @@ export interface HerdInfo { id: string; repo: string; room: string; workspace: s
|
|
|
155
167
|
/** A herd row as `herd:list` reports it: the registry row plus how many jobs hang off it. */
|
|
156
168
|
export interface HerdListRow extends HerdInfo { jobs: number }
|
|
157
169
|
export interface HerdJobInfo { herd: string; name: string; worktree: string; branch: string | null; tree: string | null; pane: string | null; agentSession: string | null; agentId: string | null; handle: string; status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed"; disposable: boolean; lastGate: string | null; lastReport: number | null; createdAt: number; updatedAt: number }
|
|
158
|
-
/** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. */
|
|
170
|
+
/** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
|
|
159
171
|
export interface HerdStatusData {
|
|
160
172
|
herd: HerdInfo;
|
|
161
|
-
jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null }>;
|
|
173
|
+
jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null; lastGateConsumed: boolean | null }>;
|
|
162
174
|
unread: number;
|
|
163
175
|
lifecycleConnected: boolean;
|
|
164
176
|
hiddenUp: boolean | null;
|
|
@@ -643,6 +655,18 @@ export interface Commands {
|
|
|
643
655
|
"discussions:reply": { payload: { repoName: string; iid: number; discussionId: string; body: string }; data: DiscussionsWriteData };
|
|
644
656
|
"discussions:diffs": { payload: { repoName: string; iid: number }; data: DiscussionsDiffsData };
|
|
645
657
|
|
|
658
|
+
/** Positioned inline MR comment with server-side DiffNote verification:
|
|
659
|
+
posts, re-checks the created note's type from the creation response,
|
|
660
|
+
and on the silent general-note degrade deletes the stray note and
|
|
661
|
+
retries ONCE with freshly fetched diff_refs. The retry repairs only
|
|
662
|
+
the stale-diff-refs degrade; a caller-supplied position GitLab
|
|
663
|
+
rejects stays rejected, and after a second degrade both stray notes
|
|
664
|
+
are deleted and the call fails. `verified: true` means the check ran. */
|
|
665
|
+
"mr:comment-inline": {
|
|
666
|
+
payload: { repoName: string; iid: number; body: string; path: string; line: number; oldPath?: string; oldLine?: number };
|
|
667
|
+
data: { discussionId: string; noteId: number; verified: true };
|
|
668
|
+
};
|
|
669
|
+
|
|
646
670
|
/** Wire reply is `{ok:true}` on success (no `data`); a failure is `{ok:false,error}`. */
|
|
647
671
|
"mr:action": { payload: { repoName: string; iid: number; action: MRActionName; args?: unknown[] }; data: Record<string, never> };
|
|
648
672
|
"mr:fetch-job-detail": { payload: { repoName: string; iid: number; jobId: number; pipelineId?: number }; data: MrJobDetail };
|
|
@@ -664,6 +688,32 @@ export interface Commands {
|
|
|
664
688
|
|
|
665
689
|
// ─── Gate facility (BOARD-20/21) ─────────────────────────────────────────
|
|
666
690
|
"gate:open": { payload: { subject: string; kind: string; questions: GateQuestion[]; meta?: Record<string, unknown>; agent?: string; pane?: string; nudge?: { session: string }; context?: string; origin?: GateOrigin }; data: { id: string; supersededId: string | null } };
|
|
691
|
+
/** Ceremony layer over gate:open: resolves subject from the caller's
|
|
692
|
+
session (an explicit subject always wins as the subject, but still
|
|
693
|
+
picks up run linkage from the session's own running run; else its
|
|
694
|
+
single running run as `run:<id>`; else its agent record's recorded
|
|
695
|
+
subject, or `agent:<id>` when it has none), computes
|
|
696
|
+
presentation via gatePresentation, supplies nudge/origin, and omits an
|
|
697
|
+
oversized context instead of rejecting it. `meta` and `agent` forward
|
|
698
|
+
to gate:open verbatim; `origin` passthrough fields fill gaps in the
|
|
699
|
+
ceremony's own origin (presentation, paneId, runId, run-derived
|
|
700
|
+
worktree always win over a caller-supplied value for the same key).
|
|
701
|
+
Delegates to gate:open for everything else (validation, supersede,
|
|
702
|
+
events, push). */
|
|
703
|
+
"gate:ask": {
|
|
704
|
+
payload: {
|
|
705
|
+
questions: GateQuestion[];
|
|
706
|
+
context?: string;
|
|
707
|
+
kind?: string;
|
|
708
|
+
subject?: string;
|
|
709
|
+
sessionId?: string;
|
|
710
|
+
paneId?: string;
|
|
711
|
+
meta?: Record<string, unknown>;
|
|
712
|
+
agent?: string;
|
|
713
|
+
origin?: { surface?: string; tabId?: string; worktree?: string };
|
|
714
|
+
};
|
|
715
|
+
data: { id: string; presentation: "form" | "wait"; subject: string; supersededId: string | null };
|
|
716
|
+
};
|
|
667
717
|
/**
|
|
668
718
|
* A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
|
|
669
719
|
* `conflict:true` and the WINNING row, so every consumer gets the winner
|
|
@@ -704,7 +754,7 @@ export interface Commands {
|
|
|
704
754
|
"herd:gates": { payload: { herd: string }; data: { gates: GateRow[] } };
|
|
705
755
|
"herd:ask": { payload: { herd: string; job: string; session: string; pane?: string; questions: GateQuestion[]; context?: string }; data: { gate: string } };
|
|
706
756
|
"herd:milestone": { payload: { herd: string; job: string; session: string; pane?: string; artifact: string; summary?: string }; data: { gate: string; message: number } };
|
|
707
|
-
"herd:answer": { payload: { gate: string }; data: { gate: string; status: GateStatus; answer: GateAnswer | null; closedReason: GateRow["closedReason"] } };
|
|
757
|
+
"herd:answer": { payload: { gate: string; sessionId?: string }; data: { gate: string; status: GateStatus; answer: GateAnswer | null; closedReason: GateRow["closedReason"] } };
|
|
708
758
|
"herd:report": { payload: { herd: string; job: string; body: string }; data: { message: number } };
|
|
709
759
|
/** `callerWorkspace` is the attending session's own HERDR_WORKSPACE_ID: the attached tab opens there, not in the herd's workspace. */
|
|
710
760
|
"herd:attend": { payload: { herd: string; job: string; callerWorkspace: string }; data: { tab: string; pane: string } };
|
|
@@ -803,6 +853,7 @@ export const COMMAND_NAMES: readonly CommandName[] = [
|
|
|
803
853
|
"discussions:resolve",
|
|
804
854
|
"discussions:reply",
|
|
805
855
|
"discussions:diffs",
|
|
856
|
+
"mr:comment-inline",
|
|
806
857
|
"mr:action",
|
|
807
858
|
"mr:fetch-job-detail",
|
|
808
859
|
"mr:fetch-job-trace",
|
|
@@ -815,6 +866,7 @@ export const COMMAND_NAMES: readonly CommandName[] = [
|
|
|
815
866
|
"reconciler:status",
|
|
816
867
|
"reconciler:clear",
|
|
817
868
|
"gate:open",
|
|
869
|
+
"gate:ask",
|
|
818
870
|
"gate:answer",
|
|
819
871
|
"gate:wait",
|
|
820
872
|
"gate:list",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GateQuestion, GateAnswer } from "./commands.ts";
|
|
2
|
+
import { gateOptionValue } from "./gate-options.ts";
|
|
3
|
+
|
|
4
|
+
export type GateAnswerWire = GateAnswer["answers"][string];
|
|
5
|
+
|
|
6
|
+
/** Both wire shapes carry the same value underneath: bare, or {value, note?}
|
|
7
|
+
when a panel attaches free text. Validation reads only the value. */
|
|
8
|
+
export function unwrapGateAnswerValue(raw: unknown): unknown {
|
|
9
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in (raw as Record<string, unknown>)) {
|
|
10
|
+
return (raw as { value: unknown }).value;
|
|
11
|
+
}
|
|
12
|
+
return raw;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function wrapperNoteIsValid(raw: unknown): boolean {
|
|
16
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("value" in (raw as Record<string, unknown>))) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const note = (raw as Record<string, unknown>).note;
|
|
20
|
+
return note === undefined || typeof note === "string";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Option membership is required whenever a question declares options,
|
|
24
|
+
checked against the unwrapped value (every element, for multi); an
|
|
25
|
+
empty options array stays free-form. Every question id must appear as
|
|
26
|
+
an answers key. Error strings are a wire contract; packages/rt-client/test/gate-answers.test.ts pins them verbatim. */
|
|
27
|
+
export function validateGateAnswers(
|
|
28
|
+
questions: GateQuestion[],
|
|
29
|
+
answers: Record<string, unknown>,
|
|
30
|
+
): string | null {
|
|
31
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
32
|
+
for (const [qid, raw] of Object.entries(answers)) {
|
|
33
|
+
const question = byId.get(qid);
|
|
34
|
+
if (!question) return `unknown question id: ${qid}`;
|
|
35
|
+
if (!wrapperNoteIsValid(raw)) return `question ${qid} note must be a string`;
|
|
36
|
+
const value = unwrapGateAnswerValue(raw);
|
|
37
|
+
const isArray = Array.isArray(value);
|
|
38
|
+
if (question.multi && !isArray) return `question ${qid} expects an array (multi)`;
|
|
39
|
+
if (!question.multi && isArray) return `question ${qid} expects a single value`;
|
|
40
|
+
const values = isArray ? (value as unknown[]) : [value];
|
|
41
|
+
if (!values.every((v) => typeof v === "string")) return `question ${qid} value must be a string`;
|
|
42
|
+
if (question.options.length > 0) {
|
|
43
|
+
const members = question.options.map(gateOptionValue);
|
|
44
|
+
for (const v of values as string[]) {
|
|
45
|
+
if (!members.includes(v)) return `answer for "${qid}" is not one of its options: "${v}"`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const missing = questions
|
|
50
|
+
.map((q) => q.id)
|
|
51
|
+
.filter((id) => !Object.prototype.hasOwnProperty.call(answers, id));
|
|
52
|
+
if (missing.length > 0) return `missing answer(s) for: ${missing.join(", ")}`;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { GateOption, GateQuestion } from "./commands.ts";
|
|
2
|
+
|
|
3
|
+
/** The canonical stored/emitted option shape (contract C11). GateOption
|
|
4
|
+
(the input union) is unchanged; rows normalized by the daemon always
|
|
5
|
+
satisfy this. */
|
|
6
|
+
export interface GateOptionObject {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function gateOptionValue(o: GateOption): string {
|
|
12
|
+
return typeof o === "string" ? o : o.value;
|
|
13
|
+
}
|
|
14
|
+
export function gateOptionLabel(o: GateOption): string {
|
|
15
|
+
return typeof o === "string" ? o : (o.label || o.value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Suffix gate-kit's stripRecommended (mattstack-apps repo,
|
|
19
|
+
packages/gate-kit/src/options.ts) parses off a label to render its own
|
|
20
|
+
"recommended" badge. This is the
|
|
21
|
+
wire representation of `recommended: true` -- the flag itself never
|
|
22
|
+
reaches the normalized output. */
|
|
23
|
+
const RECOMMENDED_SUFFIX = " (Recommended)";
|
|
24
|
+
const HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
|
|
25
|
+
|
|
26
|
+
/** A label is word-like -- eligible for auto-capitalization -- only when it
|
|
27
|
+
starts with a lowercase ASCII letter, has no digit/`/`/`:`/`\`/`@`
|
|
28
|
+
anywhere (those mark paths, ids, and verb:token pairs that must not be
|
|
29
|
+
reworded), and has no uppercase letter already (mixed-case labels like
|
|
30
|
+
"gitLab" are left as the caller spelled them). */
|
|
31
|
+
function isWordLikeLabel(label: string): boolean {
|
|
32
|
+
return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function capitalize(label: string): string {
|
|
36
|
+
return isWordLikeLabel(label) ? label[0]!.toUpperCase() + label.slice(1) : label;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bare string s becomes {value: s, label: s}; a well-formed {value,label}
|
|
40
|
+
object passes through untouched. Total over whatever actually arrives
|
|
41
|
+
on the wire, not just the declared GateOption union: a partial object
|
|
42
|
+
fills the missing field from the one present, and anything else
|
|
43
|
+
(null, a number, an object with neither field) is coerced via String()
|
|
44
|
+
into both fields. The resulting label is then capitalized when
|
|
45
|
+
word-like (see isWordLikeLabel), and an object form's `recommended:
|
|
46
|
+
true` lifts into a " (Recommended)" label suffix -- guarded against
|
|
47
|
+
double-appending -- since `recommended` itself does not survive into
|
|
48
|
+
the returned object; the suffix IS its wire representation. Every
|
|
49
|
+
returned entry is a full {value,label} pair -- callers may trust the
|
|
50
|
+
return type without re-checking it. Pure and order-preserving. */
|
|
51
|
+
export function normalizeGateOptions(options: GateOption[]): GateOptionObject[] {
|
|
52
|
+
return options.map((o) => {
|
|
53
|
+
const recommended = o !== null && typeof o === "object" && (o as { recommended?: unknown }).recommended === true;
|
|
54
|
+
let value: string;
|
|
55
|
+
let label: string;
|
|
56
|
+
if (typeof o === "string") {
|
|
57
|
+
value = o;
|
|
58
|
+
label = o;
|
|
59
|
+
} else if (o !== null && typeof o === "object") {
|
|
60
|
+
const v = typeof (o as { value?: unknown }).value === "string" ? (o as { value: string }).value : undefined;
|
|
61
|
+
const l = typeof (o as { label?: unknown }).label === "string" ? (o as { label: string }).label : undefined;
|
|
62
|
+
value = v ?? l ?? String(o);
|
|
63
|
+
label = l ?? v ?? String(o);
|
|
64
|
+
} else {
|
|
65
|
+
value = String(o);
|
|
66
|
+
label = String(o);
|
|
67
|
+
}
|
|
68
|
+
label = capitalize(label);
|
|
69
|
+
// An empty label stays empty rather than becoming just the suffix: a
|
|
70
|
+
// downstream `label || value` fallback (gate-kit) must still see label
|
|
71
|
+
// as absent, not as a non-empty "(Recommended)" that hides the value.
|
|
72
|
+
if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label)) label += RECOMMENDED_SUFFIX;
|
|
73
|
+
return { value, label };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeGateQuestions(questions: GateQuestion[]): GateQuestion[] {
|
|
78
|
+
return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GateQuestion } from "./commands.ts";
|
|
2
|
+
|
|
3
|
+
export const GATE_FORM_OPTION_CAP = 4;
|
|
4
|
+
|
|
5
|
+
/** The ONE presentation rule (spec Phase 1): form iff an injectable pane
|
|
6
|
+
exists, a nudge target exists, and every question fits the native form's
|
|
7
|
+
per-question option cap. */
|
|
8
|
+
export function gatePresentation(args: {
|
|
9
|
+
paneId?: string | undefined;
|
|
10
|
+
sessionId?: string | undefined;
|
|
11
|
+
questions: GateQuestion[];
|
|
12
|
+
}): "form" | "wait" {
|
|
13
|
+
if (!args.paneId || !args.sessionId) return "wait";
|
|
14
|
+
return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP)
|
|
15
|
+
? "form"
|
|
16
|
+
: "wait";
|
|
17
|
+
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe entry point (the "./gate" subpath export): the pure gate
|
|
3
|
+
* helpers only, so a browser bundle (mattstack-apps gate-kit) never drags in
|
|
4
|
+
* commands.ts's Node-only neighbors the way importing from "." would.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./gate-answers.ts";
|
|
7
|
+
export * from "./gate-options.ts";
|
|
8
|
+
export * from "./gate-presentation.ts";
|
|
9
|
+
export type { GateOption, GateQuestion, GateAnswer } from "./commands.ts";
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export {
|
|
|
48
48
|
reconcilerStatus,
|
|
49
49
|
reconcilerClear,
|
|
50
50
|
gateOpen,
|
|
51
|
+
gateAsk,
|
|
51
52
|
gateAnswer,
|
|
52
53
|
gateWait,
|
|
53
54
|
gateList,
|
|
@@ -77,6 +78,11 @@ export {
|
|
|
77
78
|
} from "./client.ts";
|
|
78
79
|
|
|
79
80
|
export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
|
|
81
|
+
export { GATE_FORM_OPTION_CAP, gatePresentation } from "./gate-presentation.ts";
|
|
82
|
+
export { normalizeGateOptions, normalizeGateQuestions } from "./gate-options.ts";
|
|
83
|
+
export type { GateOptionObject } from "./gate-options.ts";
|
|
84
|
+
export { unwrapGateAnswerValue, validateGateAnswers } from "./gate-answers.ts";
|
|
85
|
+
export type { GateAnswerWire } from "./gate-answers.ts";
|
|
80
86
|
export type {
|
|
81
87
|
Discussion,
|
|
82
88
|
DemandDecl,
|