@oxygen-agent/cli 1.638.1 → 1.662.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/README.md +1 -1
- package/dist/command-manifest.js +5 -2
- package/dist/index.js +956 -124
- package/node_modules/@oxygen/shared/dist/axiom-field-budget.d.ts +91 -0
- package/node_modules/@oxygen/shared/dist/axiom-field-budget.js +262 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +55 -0
- package/node_modules/@oxygen/shared/dist/collab.d.ts +228 -0
- package/node_modules/@oxygen/shared/dist/collab.js +410 -0
- package/node_modules/@oxygen/shared/dist/column-types.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/column-types.js +26 -1
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +30 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +7 -6
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +69 -8
- package/node_modules/@oxygen/shared/dist/tags.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/tags.js +2 -5
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `oxygen-logs` field budget — one shared contract for both log shippers.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Axiom caps a dataset at 1,024 distinct fields. `oxygen-logs`
|
|
5
|
+
* is shared by the Fly worker, the Vercel web runtime, and the Vercel log drain,
|
|
6
|
+
* and it reached that cap: measured 2026-08-08, **1,026 fields**. At the cap a
|
|
7
|
+
* single unrecognised key does not get dropped — Axiom rejects the ENTIRE ingest
|
|
8
|
+
* batch with HTTP 400, including every schema-stable record travelling with it.
|
|
9
|
+
*
|
|
10
|
+
* The worker already survived this, with a 400-triggered fallback that packed
|
|
11
|
+
* unknown keys into `message` and stayed there for the process lifetime. The web
|
|
12
|
+
* shipper had no such path: it logged `web.log_shipper.ingest_failed` and threw
|
|
13
|
+
* the batch away — 409 times in the 7 days to 2026-08-08, invisible to every
|
|
14
|
+
* Lane A query because the failure line could not ship itself either.
|
|
15
|
+
*
|
|
16
|
+
* So the strategy inverts. Instead of reacting to a 400, both shippers now pack
|
|
17
|
+
* PROACTIVELY: a fixed allowlist stays flat and queryable, and everything else —
|
|
18
|
+
* including every field any future code invents — is serialized into `message`
|
|
19
|
+
* under `worker_fields`. New instrumentation can then be added freely without
|
|
20
|
+
* anyone having to remember that a new key can take down log shipping globally.
|
|
21
|
+
*
|
|
22
|
+
* THE ALLOWLIST IS A LOAD-BEARING CONTRACT IN BOTH DIRECTIONS.
|
|
23
|
+
*
|
|
24
|
+
* - A field that a monitor, dashboard, or checked-in query reads **flat** must be
|
|
25
|
+
* in this set. Removing one does not break ingestion; it silently moves the
|
|
26
|
+
* value into `message` and the query starts returning zero, which reads as an
|
|
27
|
+
* all-clear forever.
|
|
28
|
+
* - A field that a monitor reads **out of `worker_fields`** must NOT be here.
|
|
29
|
+
* `[P3] OXYGEN Prod Egress order reconcile stuck` does
|
|
30
|
+
* `extend wf = parse_json(tostring(message)).worker_fields | dcount(tostring(wf.order_ref))`
|
|
31
|
+
* — promoting `order_ref` to a flat field would make `wf.order_ref` null and
|
|
32
|
+
* that monitor would report zero stuck orders while orders were stuck.
|
|
33
|
+
*
|
|
34
|
+
* Both directions are asserted by `scripts/ci/log-field-budget.mjs` against the
|
|
35
|
+
* real seed and dashboard JSON, so a change to either side fails loudly instead
|
|
36
|
+
* of quietly blinding a monitor.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Fields that stay top-level in `oxygen-logs`.
|
|
40
|
+
*
|
|
41
|
+
* Grouped by why each group is here. Do not add a field without a reader — the
|
|
42
|
+
* whole point of the budget is that flat columns are a scarce, spoken-for
|
|
43
|
+
* resource.
|
|
44
|
+
*/
|
|
45
|
+
export declare const AXIOM_STABLE_FIELDS: ReadonlySet<string>;
|
|
46
|
+
/**
|
|
47
|
+
* The Axiom **map field** every non-allowlisted key is nested under.
|
|
48
|
+
*
|
|
49
|
+
* Map fields are Axiom's own answer to this problem and they are strictly better
|
|
50
|
+
* than the JSON-string packing this module shipped first: data inside a map field
|
|
51
|
+
* does not count toward the dataset field limit at all, AND it stays natively
|
|
52
|
+
* queryable with its types intact — `['worker_fields']['order_ref']`, no
|
|
53
|
+
* `parse_json(tostring(message))` wrapper, no everything-becomes-a-string.
|
|
54
|
+
*
|
|
55
|
+
* Proven on `oxygen-logs-dev` 2026-08-08: three records carrying three novel keys
|
|
56
|
+
* plus a nested object moved the dataset field count by exactly **+1** — the map
|
|
57
|
+
* field itself — where the same records as flat keys would have cost five columns.
|
|
58
|
+
*
|
|
59
|
+
* The name is deliberately unchanged from the old JSON blob's inner key, so every
|
|
60
|
+
* runbook, monitor, and memory that says "overflow lives in worker_fields" is
|
|
61
|
+
* still true; only the access path got shorter.
|
|
62
|
+
*
|
|
63
|
+
* The map field must EXIST on the dataset before ingest, or the keys land as
|
|
64
|
+
* ordinary flat fields and the budget is defeated silently. Create it with:
|
|
65
|
+
* POST /v2/datasets/{dataset}/mapfields {"name": "worker_fields"}
|
|
66
|
+
* `scripts/ops/axiom-mapfields.mjs` does this idempotently for every log dataset.
|
|
67
|
+
*/
|
|
68
|
+
export declare const AXIOM_OVERFLOW_MAP_FIELD = "worker_fields";
|
|
69
|
+
/**
|
|
70
|
+
* Bound on the serialized overflow, so one pathological record cannot push a whole
|
|
71
|
+
* batch past Axiom's request limits. 64 KB is far above any real log line and far
|
|
72
|
+
* below anything that would matter.
|
|
73
|
+
*/
|
|
74
|
+
export declare const MAX_PACKED_MESSAGE_LENGTH: number;
|
|
75
|
+
/**
|
|
76
|
+
* Splits a flat `log()` record into the allowlisted columns plus one map field
|
|
77
|
+
* holding everything else.
|
|
78
|
+
*
|
|
79
|
+
* The resulting shape is the query contract:
|
|
80
|
+
*
|
|
81
|
+
* ```kusto
|
|
82
|
+
* ['oxygen-logs']
|
|
83
|
+
* | where msg == 'worker.egress_order_reconcile_stuck'
|
|
84
|
+
* | summarize dcount(tostring(['worker_fields']['order_ref']))
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* `worker_fields` keeps its name even for web records: it is already the word
|
|
88
|
+
* every existing monitor, dashboard, and runbook uses for "the overflow", and
|
|
89
|
+
* renaming it would silently zero those queries for no gain.
|
|
90
|
+
*/
|
|
91
|
+
export declare function packAxiomLogRecord(record: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `oxygen-logs` field budget — one shared contract for both log shippers.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Axiom caps a dataset at 1,024 distinct fields. `oxygen-logs`
|
|
5
|
+
* is shared by the Fly worker, the Vercel web runtime, and the Vercel log drain,
|
|
6
|
+
* and it reached that cap: measured 2026-08-08, **1,026 fields**. At the cap a
|
|
7
|
+
* single unrecognised key does not get dropped — Axiom rejects the ENTIRE ingest
|
|
8
|
+
* batch with HTTP 400, including every schema-stable record travelling with it.
|
|
9
|
+
*
|
|
10
|
+
* The worker already survived this, with a 400-triggered fallback that packed
|
|
11
|
+
* unknown keys into `message` and stayed there for the process lifetime. The web
|
|
12
|
+
* shipper had no such path: it logged `web.log_shipper.ingest_failed` and threw
|
|
13
|
+
* the batch away — 409 times in the 7 days to 2026-08-08, invisible to every
|
|
14
|
+
* Lane A query because the failure line could not ship itself either.
|
|
15
|
+
*
|
|
16
|
+
* So the strategy inverts. Instead of reacting to a 400, both shippers now pack
|
|
17
|
+
* PROACTIVELY: a fixed allowlist stays flat and queryable, and everything else —
|
|
18
|
+
* including every field any future code invents — is serialized into `message`
|
|
19
|
+
* under `worker_fields`. New instrumentation can then be added freely without
|
|
20
|
+
* anyone having to remember that a new key can take down log shipping globally.
|
|
21
|
+
*
|
|
22
|
+
* THE ALLOWLIST IS A LOAD-BEARING CONTRACT IN BOTH DIRECTIONS.
|
|
23
|
+
*
|
|
24
|
+
* - A field that a monitor, dashboard, or checked-in query reads **flat** must be
|
|
25
|
+
* in this set. Removing one does not break ingestion; it silently moves the
|
|
26
|
+
* value into `message` and the query starts returning zero, which reads as an
|
|
27
|
+
* all-clear forever.
|
|
28
|
+
* - A field that a monitor reads **out of `worker_fields`** must NOT be here.
|
|
29
|
+
* `[P3] OXYGEN Prod Egress order reconcile stuck` does
|
|
30
|
+
* `extend wf = parse_json(tostring(message)).worker_fields | dcount(tostring(wf.order_ref))`
|
|
31
|
+
* — promoting `order_ref` to a flat field would make `wf.order_ref` null and
|
|
32
|
+
* that monitor would report zero stuck orders while orders were stuck.
|
|
33
|
+
*
|
|
34
|
+
* Both directions are asserted by `scripts/ci/log-field-budget.mjs` against the
|
|
35
|
+
* real seed and dashboard JSON, so a change to either side fails loudly instead
|
|
36
|
+
* of quietly blinding a monitor.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Fields that stay top-level in `oxygen-logs`.
|
|
40
|
+
*
|
|
41
|
+
* Grouped by why each group is here. Do not add a field without a reader — the
|
|
42
|
+
* whole point of the budget is that flat columns are a scarce, spoken-for
|
|
43
|
+
* resource.
|
|
44
|
+
*/
|
|
45
|
+
export const AXIOM_STABLE_FIELDS = new Set([
|
|
46
|
+
// --- Envelope stamped by log() on every record ---------------------------
|
|
47
|
+
"ts",
|
|
48
|
+
"level",
|
|
49
|
+
"msg",
|
|
50
|
+
"service_name",
|
|
51
|
+
"oxygen_version",
|
|
52
|
+
"sha",
|
|
53
|
+
"region",
|
|
54
|
+
"env",
|
|
55
|
+
"surface",
|
|
56
|
+
// --- Ambient LogContext (packages/shared/src/log.ts) ----------------------
|
|
57
|
+
// These are the correlation dimensions every query joins on. A record that
|
|
58
|
+
// loses them is debuggable only by full-text search.
|
|
59
|
+
"request_id",
|
|
60
|
+
"trace_id",
|
|
61
|
+
"org_id",
|
|
62
|
+
"organization_id",
|
|
63
|
+
"user_id",
|
|
64
|
+
"tool_name",
|
|
65
|
+
"provider",
|
|
66
|
+
"operation",
|
|
67
|
+
"tenant_database_id",
|
|
68
|
+
"run_id",
|
|
69
|
+
"chunk_id",
|
|
70
|
+
"item_id",
|
|
71
|
+
"worker_id",
|
|
72
|
+
"conversation_id",
|
|
73
|
+
// --- Error shape (errorFields) -------------------------------------------
|
|
74
|
+
"error_id",
|
|
75
|
+
"error_name",
|
|
76
|
+
"error_message",
|
|
77
|
+
"error_code",
|
|
78
|
+
// Next.js stamps `digest` on a serialized server error; it is the only value
|
|
79
|
+
// that joins a browser error report back to its `next.request_error` row.
|
|
80
|
+
"digest",
|
|
81
|
+
// --- Primitive identity --------------------------------------------------
|
|
82
|
+
"workflow_id",
|
|
83
|
+
"workflow_run_id",
|
|
84
|
+
"table_id",
|
|
85
|
+
"row_id",
|
|
86
|
+
"column_id",
|
|
87
|
+
"action_run_id",
|
|
88
|
+
"trigger_id",
|
|
89
|
+
// --- Request/route shape (web, CLI, MCP) ---------------------------------
|
|
90
|
+
// Read by `cli.command_failed`, `next.request_error`, and the MCP/CLI health
|
|
91
|
+
// dashboard, which groups failures by route and command.
|
|
92
|
+
"path",
|
|
93
|
+
"route",
|
|
94
|
+
"route_path",
|
|
95
|
+
"route_type",
|
|
96
|
+
"router_kind",
|
|
97
|
+
"method",
|
|
98
|
+
"command",
|
|
99
|
+
"latency_ms",
|
|
100
|
+
"framework_code",
|
|
101
|
+
"component",
|
|
102
|
+
// --- Outcome vocabulary --------------------------------------------------
|
|
103
|
+
"mode",
|
|
104
|
+
"status",
|
|
105
|
+
"outcome",
|
|
106
|
+
"reason",
|
|
107
|
+
"role",
|
|
108
|
+
"phase",
|
|
109
|
+
"step",
|
|
110
|
+
"dataset",
|
|
111
|
+
// --- Monitor-critical gauges ---------------------------------------------
|
|
112
|
+
// Each of these is read FLAT by a monitor in docs/observability/monitors.json.
|
|
113
|
+
// `broken_tenant_oldest_age_ms` is the value `[P1] Tenant database broken`
|
|
114
|
+
// alerts on; `pending_count` is what `[P2] tenant migration drift` minimises.
|
|
115
|
+
"broken_tenant_oldest_age_ms",
|
|
116
|
+
"pending_count",
|
|
117
|
+
"divergence",
|
|
118
|
+
"window_start",
|
|
119
|
+
// Group keys for `notifyByGroup` alerts. These are what put the offender in
|
|
120
|
+
// the notification subject — packed away, the alert still fires and names
|
|
121
|
+
// nobody, which is the whole reason those alerts were grouped.
|
|
122
|
+
"sender_account_id",
|
|
123
|
+
"stripe_event_type",
|
|
124
|
+
// --- Worker queue counters (worker cycle telemetry) ----------------------
|
|
125
|
+
"queue",
|
|
126
|
+
"ready",
|
|
127
|
+
"claimable",
|
|
128
|
+
"leased",
|
|
129
|
+
"oldest_pending_age_seconds",
|
|
130
|
+
"stalled_tenant_count",
|
|
131
|
+
"active_runs",
|
|
132
|
+
"skipped_to",
|
|
133
|
+
"ticket_reason",
|
|
134
|
+
"queue_wait_ms",
|
|
135
|
+
"ticket_age_ms",
|
|
136
|
+
"quantum_duration_ms",
|
|
137
|
+
"tenants",
|
|
138
|
+
"claimed",
|
|
139
|
+
"completed",
|
|
140
|
+
"skipped",
|
|
141
|
+
"retrying",
|
|
142
|
+
"failed",
|
|
143
|
+
"expired",
|
|
144
|
+
"finalized",
|
|
145
|
+
"repaired",
|
|
146
|
+
"chainChildrenDispatched",
|
|
147
|
+
"ingestionClaimed",
|
|
148
|
+
"ingestionCompleted",
|
|
149
|
+
"ingestionRetrying",
|
|
150
|
+
"ingestionFailed",
|
|
151
|
+
"ingestionExpired",
|
|
152
|
+
"ingestionFinalized",
|
|
153
|
+
"ingestionRepaired",
|
|
154
|
+
"workflowClaimed",
|
|
155
|
+
"workflowCompleted",
|
|
156
|
+
"workflowRetrying",
|
|
157
|
+
"workflowFailed",
|
|
158
|
+
"workflowExpired",
|
|
159
|
+
"workflowAwaitingApproval",
|
|
160
|
+
"workflowApprovalsExpired",
|
|
161
|
+
"workflowWaiting",
|
|
162
|
+
"workflowCronInitialized",
|
|
163
|
+
"workflowCronEnqueued",
|
|
164
|
+
"workflowCronFailed",
|
|
165
|
+
"linkedinPlanned",
|
|
166
|
+
"linkedinDispatched",
|
|
167
|
+
"linkedinDeferred",
|
|
168
|
+
"linkedinSkipped",
|
|
169
|
+
"linkedinFailed",
|
|
170
|
+
"publishingClaimed",
|
|
171
|
+
"publishingPublished",
|
|
172
|
+
"publishingFailed",
|
|
173
|
+
"publishingDeferred",
|
|
174
|
+
"publishingLeasesRevived",
|
|
175
|
+
"publishingLeasesFailed",
|
|
176
|
+
]);
|
|
177
|
+
/**
|
|
178
|
+
* The Axiom **map field** every non-allowlisted key is nested under.
|
|
179
|
+
*
|
|
180
|
+
* Map fields are Axiom's own answer to this problem and they are strictly better
|
|
181
|
+
* than the JSON-string packing this module shipped first: data inside a map field
|
|
182
|
+
* does not count toward the dataset field limit at all, AND it stays natively
|
|
183
|
+
* queryable with its types intact — `['worker_fields']['order_ref']`, no
|
|
184
|
+
* `parse_json(tostring(message))` wrapper, no everything-becomes-a-string.
|
|
185
|
+
*
|
|
186
|
+
* Proven on `oxygen-logs-dev` 2026-08-08: three records carrying three novel keys
|
|
187
|
+
* plus a nested object moved the dataset field count by exactly **+1** — the map
|
|
188
|
+
* field itself — where the same records as flat keys would have cost five columns.
|
|
189
|
+
*
|
|
190
|
+
* The name is deliberately unchanged from the old JSON blob's inner key, so every
|
|
191
|
+
* runbook, monitor, and memory that says "overflow lives in worker_fields" is
|
|
192
|
+
* still true; only the access path got shorter.
|
|
193
|
+
*
|
|
194
|
+
* The map field must EXIST on the dataset before ingest, or the keys land as
|
|
195
|
+
* ordinary flat fields and the budget is defeated silently. Create it with:
|
|
196
|
+
* POST /v2/datasets/{dataset}/mapfields {"name": "worker_fields"}
|
|
197
|
+
* `scripts/ops/axiom-mapfields.mjs` does this idempotently for every log dataset.
|
|
198
|
+
*/
|
|
199
|
+
export const AXIOM_OVERFLOW_MAP_FIELD = "worker_fields";
|
|
200
|
+
/**
|
|
201
|
+
* Bound on the serialized overflow, so one pathological record cannot push a whole
|
|
202
|
+
* batch past Axiom's request limits. 64 KB is far above any real log line and far
|
|
203
|
+
* below anything that would matter.
|
|
204
|
+
*/
|
|
205
|
+
export const MAX_PACKED_MESSAGE_LENGTH = 64 * 1024;
|
|
206
|
+
/**
|
|
207
|
+
* Splits a flat `log()` record into the allowlisted columns plus one map field
|
|
208
|
+
* holding everything else.
|
|
209
|
+
*
|
|
210
|
+
* The resulting shape is the query contract:
|
|
211
|
+
*
|
|
212
|
+
* ```kusto
|
|
213
|
+
* ['oxygen-logs']
|
|
214
|
+
* | where msg == 'worker.egress_order_reconcile_stuck'
|
|
215
|
+
* | summarize dcount(tostring(['worker_fields']['order_ref']))
|
|
216
|
+
* ```
|
|
217
|
+
*
|
|
218
|
+
* `worker_fields` keeps its name even for web records: it is already the word
|
|
219
|
+
* every existing monitor, dashboard, and runbook uses for "the overflow", and
|
|
220
|
+
* renaming it would silently zero those queries for no gain.
|
|
221
|
+
*/
|
|
222
|
+
export function packAxiomLogRecord(record) {
|
|
223
|
+
const stable = {};
|
|
224
|
+
const overflow = {};
|
|
225
|
+
for (const [key, value] of Object.entries(record)) {
|
|
226
|
+
if (AXIOM_STABLE_FIELDS.has(key))
|
|
227
|
+
stable[key] = value;
|
|
228
|
+
else
|
|
229
|
+
overflow[key] = value;
|
|
230
|
+
}
|
|
231
|
+
// The capped dataset predates the Copilot column names, so `turn_id` and
|
|
232
|
+
// `session_id` cannot become flat fields. Copy them into the semantically
|
|
233
|
+
// equivalent correlation columns that DO exist — a Langfuse trace id is the
|
|
234
|
+
// turn id, and a Copilot session is the conversation grouping its turns —
|
|
235
|
+
// while leaving the exact original names inside the packed blob.
|
|
236
|
+
const copilotTraceId = record.turn_id ?? record.langfuse_trace_id;
|
|
237
|
+
if (stable.trace_id === undefined &&
|
|
238
|
+
typeof copilotTraceId === "string" &&
|
|
239
|
+
copilotTraceId.length > 0) {
|
|
240
|
+
stable.trace_id = copilotTraceId;
|
|
241
|
+
}
|
|
242
|
+
if (stable.conversation_id === undefined &&
|
|
243
|
+
typeof record.session_id === "string" &&
|
|
244
|
+
record.session_id.length > 0) {
|
|
245
|
+
stable.conversation_id = record.session_id;
|
|
246
|
+
}
|
|
247
|
+
if (Object.keys(overflow).length === 0)
|
|
248
|
+
return stable;
|
|
249
|
+
// Nested under the map field as a real object — NOT stringified. Axiom stores
|
|
250
|
+
// map contents without allocating a column per key, and keeps them queryable
|
|
251
|
+
// and typed.
|
|
252
|
+
stable[AXIOM_OVERFLOW_MAP_FIELD] =
|
|
253
|
+
JSON.stringify(overflow).length <= MAX_PACKED_MESSAGE_LENGTH
|
|
254
|
+
? overflow
|
|
255
|
+
: {
|
|
256
|
+
// Never silently truncate to nothing: the key NAMES alone are usually
|
|
257
|
+
// enough to identify which call site produced the oversized record.
|
|
258
|
+
_truncated: true,
|
|
259
|
+
_keys: Object.keys(overflow),
|
|
260
|
+
};
|
|
261
|
+
return stable;
|
|
262
|
+
}
|
|
@@ -87,6 +87,42 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
87
87
|
endpointSections: ["admin", "db", "worker"],
|
|
88
88
|
intentTerms: ["admin", "worker", "queue", "tenant repair", "database", "db status", "stuck jobs", "operations"],
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
// Control-layer interaction state, deliberately NOT a sixteenth primitive
|
|
92
|
+
// (ADR 0020): a comment or an approval request is state ABOUT a GTM
|
|
93
|
+
// artifact, and the hard gate is honored by the owning primitive's own
|
|
94
|
+
// route. `primitive: null` is what keeps the fifteen-primitive assertion
|
|
95
|
+
// in OXYGEN_PRIMITIVE_ROUTES true.
|
|
96
|
+
id: "collaboration",
|
|
97
|
+
layer: "Control",
|
|
98
|
+
primitive: null,
|
|
99
|
+
owns: "Threaded internal comments on any workspace object, second-party approval requests, the opt-in gate that blocks a live action until approved, and the restricted client role.",
|
|
100
|
+
notFor: "Public comments on a published post (Posts/Publishing), the artifact itself, or a run's own approval card — those belong to their owning primitive.",
|
|
101
|
+
execution: "Comment or request a decision on a subject reference (`<kind>:<id>`); an admin arms the gate, and the owning primitive refuses its live path until an approval lands.",
|
|
102
|
+
posture: "workspace_write",
|
|
103
|
+
gatewayTools: ["oxygen_approvals_inbox", "oxygen_approvals_request", "oxygen_approvals_decide", "oxygen_comments_add", "oxygen_comments_list", "oxygen_approvals_gate_set"],
|
|
104
|
+
gatewayCommands: ["approvals inbox", "approvals request", "approvals decide", "comments add", "comments list", "approvals gate set"],
|
|
105
|
+
skills: ["oxygen-collaboration"],
|
|
106
|
+
endpointSections: ["collab"],
|
|
107
|
+
intentTerms: [
|
|
108
|
+
"collaboration",
|
|
109
|
+
"comment thread",
|
|
110
|
+
"internal comment",
|
|
111
|
+
"leave a comment",
|
|
112
|
+
"sign off",
|
|
113
|
+
"signoff",
|
|
114
|
+
"approval request",
|
|
115
|
+
"request approval",
|
|
116
|
+
"approval gate",
|
|
117
|
+
"client approval",
|
|
118
|
+
"client review",
|
|
119
|
+
"client login",
|
|
120
|
+
"client role",
|
|
121
|
+
"go no go",
|
|
122
|
+
"waiting on me",
|
|
123
|
+
],
|
|
124
|
+
negativeTerms: ["public comment", "linkedin comment", "post comment"],
|
|
125
|
+
},
|
|
90
126
|
{
|
|
91
127
|
id: "agency-directory",
|
|
92
128
|
layer: "Control",
|
|
@@ -475,6 +511,16 @@ function explicitCapabilityIntent(query) {
|
|
|
475
511
|
return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
|
|
476
512
|
if (isOwnedPostCommentIntent(query))
|
|
477
513
|
return ROUTE_BY_PRIMITIVE.get("posts") ?? null;
|
|
514
|
+
// Asking a second party to decide is Collaboration even when the thing being
|
|
515
|
+
// decided is a campaign, a table, or a post — the subject noun would
|
|
516
|
+
// otherwise win outright ("campaign" is an explicit Sequences intent below,
|
|
517
|
+
// and that is right for every ask that is not about the decision itself).
|
|
518
|
+
// Deliberately narrow: bare "approve"/"approval" stays with the owning
|
|
519
|
+
// primitive (a publishing approval, an Observability approval queue), and
|
|
520
|
+
// this sits after the owned-post-comment check so a PUBLIC comment on a
|
|
521
|
+
// published post still routes to Posts.
|
|
522
|
+
if (isSecondPartyDecisionIntent(query))
|
|
523
|
+
return ROUTE_BY_ID.get("collaboration") ?? null;
|
|
478
524
|
// A deferred acquisition motion starts with the public-data owner. This keeps
|
|
479
525
|
// "find/qualify now, contact later" from skipping straight to the final write
|
|
480
526
|
// step; the recommendation still hands the eventual initiation to Sequences.
|
|
@@ -743,6 +789,15 @@ function isNetNewLinkedInInitiation(query) {
|
|
|
743
789
|
return false;
|
|
744
790
|
return !/\b(sequence|campaign|cadence|enroll|nurture|multi[ -]step|multi[ -]recipient|leads|contacts|recipients|audience|rotate senders?|stop on reply|day\s*\d+)\b/.test(query);
|
|
745
791
|
}
|
|
792
|
+
function isSecondPartyDecisionIntent(query) {
|
|
793
|
+
const decisionLanguage = /\b(sign ?off|signoff|approval request|request approval|approval gate|go no go)\b/.test(query);
|
|
794
|
+
const clientDecision = /\bclient\b.{0,20}\b(approv\w*|sign ?off|review|decide|decision|login|role)\b/.test(query)
|
|
795
|
+
|| /\b(approv\w*|sign ?off|decision)\b.{0,20}\bfrom the client\b/.test(query);
|
|
796
|
+
const internalThread = /\b(comment|comments|thread)\b/.test(query)
|
|
797
|
+
&& /\b(internal|client|teammate|workspace member|discuss)\b/.test(query)
|
|
798
|
+
&& !/\b(linkedin|public|published|post|posts|publishing)\b/.test(query);
|
|
799
|
+
return decisionLanguage || clientDecision || internalThread;
|
|
800
|
+
}
|
|
746
801
|
function isOwnedPostCommentIntent(query) {
|
|
747
802
|
if (!/\bcomments?\b/.test(query) || !/\b(linkedin|posts?|publishing)\b/.test(query))
|
|
748
803
|
return false;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { type TagKind } from "./tags.js";
|
|
2
|
+
/**
|
|
3
|
+
* Workspace objects that can carry a comment thread or an approval request.
|
|
4
|
+
*
|
|
5
|
+
* Seeded from {@link TAG_KINDS}: anything worth labelling for a campaign is
|
|
6
|
+
* worth discussing with the client who signs that campaign off. The list is
|
|
7
|
+
* RESTATED rather than aliased because it is also a stored value — the
|
|
8
|
+
* `ox_collab` CHECK constraint and existing rows pin it — so widening the tag
|
|
9
|
+
* vocabulary must be a deliberate decision here, not a silent side effect that
|
|
10
|
+
* lets the API accept a kind the database rejects. The alignment is enforced
|
|
11
|
+
* both ways: {@link EveryTagKindIsCommentable} fails the build if a taggable
|
|
12
|
+
* kind is missing, and the paired test pins today's list exactly.
|
|
13
|
+
*/
|
|
14
|
+
export declare const COLLAB_SUBJECT_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project"];
|
|
15
|
+
export type CollabSubjectKind = (typeof COLLAB_SUBJECT_KINDS)[number];
|
|
16
|
+
/** Compile-time assertion helper: instantiating with `false` is a type error. */
|
|
17
|
+
type AssertTrue<T extends true> = T;
|
|
18
|
+
/**
|
|
19
|
+
* Build-time proof that every taggable kind is also commentable. Adding a kind
|
|
20
|
+
* to {@link TAG_KINDS} without adding it here is a compile error, which is the
|
|
21
|
+
* point: the alternative is an API that accepts `--on <newkind>:<id>` and a
|
|
22
|
+
* database that rejects the insert.
|
|
23
|
+
*
|
|
24
|
+
* Divergence is legal in the other direction only — Collaboration may grow a
|
|
25
|
+
* subject kind that carries no tags.
|
|
26
|
+
*/
|
|
27
|
+
export type EveryTagKindIsCommentable = AssertTrue<TagKind extends CollabSubjectKind ? true : false>;
|
|
28
|
+
export declare function isCollabSubjectKind(value: unknown): value is CollabSubjectKind;
|
|
29
|
+
/**
|
|
30
|
+
* How each subject kind is named in output. Total over CollabSubjectKind, so
|
|
31
|
+
* adding a kind is a compile error until it is labelled — the guarantee
|
|
32
|
+
* `TAG_KIND_LABELS` had to learn the hard way when three hand-maintained copies
|
|
33
|
+
* of the same map drifted apart.
|
|
34
|
+
*
|
|
35
|
+
* `many` keeps proper nouns intact ("CRM records") so an inline count reads
|
|
36
|
+
* correctly; headings run it through {@link collabSubjectHeading}, which only
|
|
37
|
+
* uppercases a leading lowercase letter.
|
|
38
|
+
*/
|
|
39
|
+
export declare const COLLAB_SUBJECT_LABELS: Record<CollabSubjectKind, {
|
|
40
|
+
one: string;
|
|
41
|
+
many: string;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* What a thread on each kind is usually about, for `--help` and MCP tool
|
|
45
|
+
* descriptions. Total over CollabSubjectKind for the same reason the labels
|
|
46
|
+
* are: a blind-user eval (2026-07-21) found `tags --help` still advertising 7
|
|
47
|
+
* of 13 kinds because the prose was hand-written beside a hand-maintained
|
|
48
|
+
* marker map, so six taggable kinds were invisible from the one place a user
|
|
49
|
+
* looks. Here the help text cannot omit a kind — the compiler rejects the map
|
|
50
|
+
* until the kind is glossed, and {@link COLLAB_SUBJECT_KINDS_PROSE} is DERIVED
|
|
51
|
+
* from the labels rather than typed out a second time.
|
|
52
|
+
*/
|
|
53
|
+
export declare const COLLAB_SUBJECT_PROSE: Record<CollabSubjectKind, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Every commentable kind in prose, for CLI `--help` and MCP tool descriptions.
|
|
56
|
+
* Derived from {@link COLLAB_SUBJECT_LABELS} on purpose: a hand-written copy is
|
|
57
|
+
* exactly what drifted in the tags help.
|
|
58
|
+
*/
|
|
59
|
+
export declare const COLLAB_SUBJECT_KINDS_PROSE: string;
|
|
60
|
+
/** Inline label for a count: `collabSubjectLabel("sequence", 1)` -> "sequence". */
|
|
61
|
+
export declare function collabSubjectLabel(kind: string, count: number): string;
|
|
62
|
+
/** Section heading for a kind: "Sequences", "CRM records". */
|
|
63
|
+
export declare function collabSubjectHeading(kind: string): string;
|
|
64
|
+
/** A thread's or request's subject: which kind of object, and which one. */
|
|
65
|
+
export type CollabSubjectRef = {
|
|
66
|
+
kind: CollabSubjectKind;
|
|
67
|
+
id: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Render a subject as the `<kind>:<id>` string every surface accepts
|
|
71
|
+
* (`--on sequence:9f1c...`). Throws rather than emitting an unparseable ref,
|
|
72
|
+
* because a malformed ref written into a stored row is drift nobody notices
|
|
73
|
+
* until a read fails.
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatSubjectRef(ref: CollabSubjectRef): string;
|
|
76
|
+
/**
|
|
77
|
+
* Parse `<kind>:<id>`. Splits on the FIRST colon only, so an id that itself
|
|
78
|
+
* contains colons survives the round trip; an input with no colon at all is
|
|
79
|
+
* rejected rather than being guessed at (there is no default kind — a bare id
|
|
80
|
+
* would silently address the wrong store).
|
|
81
|
+
*/
|
|
82
|
+
export declare function tryParseSubjectRef(value: unknown): CollabSubjectRef | null;
|
|
83
|
+
/** Throwing variant of {@link tryParseSubjectRef}, for surfaces that want the typed error. */
|
|
84
|
+
export declare function parseSubjectRef(value: unknown): CollabSubjectRef;
|
|
85
|
+
/**
|
|
86
|
+
* Sub-object addressing inside one subject, so commenting on a single cell does
|
|
87
|
+
* not need a thread table per kind: `row:<rowId>` for a whole row,
|
|
88
|
+
* `row:<rowId>#<columnKey>` for one cell, `column:<columnKey>` for a whole
|
|
89
|
+
* column. Unknown prefixes are still rejected rather than stored — an address no
|
|
90
|
+
* reader understands is worse than a refusal.
|
|
91
|
+
*
|
|
92
|
+
* `column:` was added in the second Collaboration pass because a column is what
|
|
93
|
+
* an agency and its client actually argue about ("why is Stage set this way?"),
|
|
94
|
+
* and it had no address at all: every path had to name a row first. It is a
|
|
95
|
+
* SIBLING of `row:`, not a variant of it, which is why the type below is a
|
|
96
|
+
* discriminated union — a column path has no row, and an optional `rowId` would
|
|
97
|
+
* let `{}` typecheck as a valid path.
|
|
98
|
+
*/
|
|
99
|
+
export declare const SUBJECT_PATH_ROW_PREFIX = "row:";
|
|
100
|
+
export declare const SUBJECT_PATH_COLUMN_PREFIX = "column:";
|
|
101
|
+
export type CollabSubjectPath = {
|
|
102
|
+
kind: "row";
|
|
103
|
+
rowId: string;
|
|
104
|
+
columnKey?: string;
|
|
105
|
+
} | {
|
|
106
|
+
kind: "column";
|
|
107
|
+
columnKey: string;
|
|
108
|
+
};
|
|
109
|
+
/** Every path form in prose, for `--help`, MCP descriptors, and error messages. */
|
|
110
|
+
export declare const SUBJECT_PATH_FORMS_PROSE = "\"row:<rowId>\" for one row, \"row:<rowId>#<columnKey>\" for one cell, or \"column:<columnKey>\" for one column";
|
|
111
|
+
/**
|
|
112
|
+
* Render a sub-object path: `{ kind: "row", rowId: "abc" }` -> "row:abc"; with a
|
|
113
|
+
* column -> "row:abc#email"; `{ kind: "column", columnKey: "email" }` ->
|
|
114
|
+
* "column:email".
|
|
115
|
+
*/
|
|
116
|
+
export declare function formatSubjectPath(path: CollabSubjectPath): string;
|
|
117
|
+
export declare function tryParseSubjectPath(value: unknown): CollabSubjectPath | null;
|
|
118
|
+
/** Throwing variant of {@link tryParseSubjectPath}. */
|
|
119
|
+
export declare function parseSubjectPath(value: unknown): CollabSubjectPath;
|
|
120
|
+
/**
|
|
121
|
+
* The gates a workspace can require. `launch` is the client's go/no-go before a
|
|
122
|
+
* campaign sends; `signoff` is their sign-off on the list that campaign sends
|
|
123
|
+
* to. Both are OPT-IN per subject (or per subject kind) — an unset gate leaves
|
|
124
|
+
* today's behavior untouched.
|
|
125
|
+
*/
|
|
126
|
+
export declare const COLLAB_GATE_KINDS: readonly ["launch", "signoff"];
|
|
127
|
+
export type CollabGateKind = (typeof COLLAB_GATE_KINDS)[number];
|
|
128
|
+
export declare function isCollabGateKind(value: unknown): value is CollabGateKind;
|
|
129
|
+
/**
|
|
130
|
+
* Which subject kinds each gate can be required on. Total over CollabGateKind,
|
|
131
|
+
* so a new gate cannot ship without naming the primitives that must honor it —
|
|
132
|
+
* a gate nobody enforces is worse than no gate, because the workspace believes
|
|
133
|
+
* it is protected.
|
|
134
|
+
*/
|
|
135
|
+
export declare const GATE_KIND_SUBJECTS: Record<CollabGateKind, readonly CollabSubjectKind[]>;
|
|
136
|
+
/** What each gate means, for `--help` and MCP descriptors. Total over CollabGateKind. */
|
|
137
|
+
export declare const COLLAB_GATE_PROSE: Record<CollabGateKind, string>;
|
|
138
|
+
export declare function isGateKindValidForSubject(gateKind: unknown, subjectKind: unknown): gateKind is CollabGateKind;
|
|
139
|
+
/** Every gate that can be required on a subject kind — drives `approvals gate list`. */
|
|
140
|
+
export declare function gateKindsForSubject(subjectKind: unknown): CollabGateKind[];
|
|
141
|
+
/** A thread is open until somebody resolves it; reopening flips it back. */
|
|
142
|
+
export declare const COLLAB_THREAD_STATUSES: readonly ["open", "resolved"];
|
|
143
|
+
export type CollabThreadStatus = (typeof COLLAB_THREAD_STATUSES)[number];
|
|
144
|
+
export declare function isCollabThreadStatus(value: unknown): value is CollabThreadStatus;
|
|
145
|
+
export declare const COLLAB_REQUEST_STATUSES: readonly ["pending", "approved", "changes_requested", "rejected", "cancelled", "expired"];
|
|
146
|
+
export type CollabRequestStatus = (typeof COLLAB_REQUEST_STATUSES)[number];
|
|
147
|
+
export declare function isCollabRequestStatus(value: unknown): value is CollabRequestStatus;
|
|
148
|
+
/**
|
|
149
|
+
* The whole feature hinges on this one line: ONLY `approved` opens a gate.
|
|
150
|
+
*
|
|
151
|
+
* `changes_requested` is a decision, and it ends the requester's wait, but it is
|
|
152
|
+
* not consent — the client asked for edits. Treating it as terminal-and-done is
|
|
153
|
+
* the obvious bug (a request that is no longer pending looks "handled"), so the
|
|
154
|
+
* gate check asks this function rather than testing `status !== "pending"`.
|
|
155
|
+
*/
|
|
156
|
+
export declare function requestStatusOpensGate(status: unknown): boolean;
|
|
157
|
+
/** Statuses that no longer await a decision. `pending` is the only live state. */
|
|
158
|
+
export declare const COLLAB_REQUEST_TERMINAL_STATUSES: readonly CollabRequestStatus[];
|
|
159
|
+
export declare function isTerminalRequestStatus(status: unknown): boolean;
|
|
160
|
+
/**
|
|
161
|
+
* What an approver may do, as the CLI/MCP spells it — and the status each
|
|
162
|
+
* decision writes. Total over CollabDecision so the API, the CLI flags, and the
|
|
163
|
+
* MCP tool cannot each invent their own mapping (the failure mode being a
|
|
164
|
+
* `changes_requested` decision stored as `rejected`, which reads to the agency
|
|
165
|
+
* as "the client said no" rather than "the client wants edits").
|
|
166
|
+
*/
|
|
167
|
+
export declare const COLLAB_DECISIONS: readonly ["approve", "reject", "changes_requested"];
|
|
168
|
+
export type CollabDecision = (typeof COLLAB_DECISIONS)[number];
|
|
169
|
+
export declare function isCollabDecision(value: unknown): value is CollabDecision;
|
|
170
|
+
export declare const COLLAB_DECISION_STATUSES: Record<CollabDecision, CollabRequestStatus>;
|
|
171
|
+
/** Why a notification exists. Delivery itself is the notifier's problem. */
|
|
172
|
+
export declare const COLLAB_NOTIFICATION_KINDS: readonly ["assigned", "mentioned", "decided", "replied"];
|
|
173
|
+
export type CollabNotificationKind = (typeof COLLAB_NOTIFICATION_KINDS)[number];
|
|
174
|
+
export declare function isCollabNotificationKind(value: unknown): value is CollabNotificationKind;
|
|
175
|
+
export declare const COLLAB_NOTIFICATION_STATUSES: readonly ["pending", "sent", "failed", "skipped"];
|
|
176
|
+
export type CollabNotificationStatus = (typeof COLLAB_NOTIFICATION_STATUSES)[number];
|
|
177
|
+
export declare function isCollabNotificationStatus(value: unknown): value is CollabNotificationStatus;
|
|
178
|
+
/**
|
|
179
|
+
* Body/field caps. Shared so the API, the CLI, and the tenant column widths
|
|
180
|
+
* agree — three independently chosen limits means the CLI accepts what the API
|
|
181
|
+
* truncates.
|
|
182
|
+
*/
|
|
183
|
+
export declare const MAX_COMMENT_BODY_LENGTH = 10000;
|
|
184
|
+
export declare const MAX_THREAD_TITLE_LENGTH = 200;
|
|
185
|
+
export declare const MAX_DECISION_NOTE_LENGTH = 2000;
|
|
186
|
+
/** Bounds the notification fan-out one comment or request can trigger. */
|
|
187
|
+
export declare const MAX_MENTIONS_PER_COMMENT = 25;
|
|
188
|
+
export declare const MAX_ASSIGNEES_PER_REQUEST = 10;
|
|
189
|
+
/**
|
|
190
|
+
* Normalize one comment body: trim, reject blanks, non-strings, and anything
|
|
191
|
+
* over {@link MAX_COMMENT_BODY_LENGTH}. Returns `null` so each surface can raise
|
|
192
|
+
* its own typed error (the CLI wants an exit code, the API wants a status).
|
|
193
|
+
*
|
|
194
|
+
* Deliberately does NOT strip or rewrite the body beyond trimming — a comment is
|
|
195
|
+
* the client's words, and Markdown, quoted text, and whitespace layout are part
|
|
196
|
+
* of what they wrote.
|
|
197
|
+
*/
|
|
198
|
+
export declare function normalizeCommentBody(value: unknown): string | null;
|
|
199
|
+
/**
|
|
200
|
+
* The Oxygen-side membership role overlay (control DB
|
|
201
|
+
* `organization_memberships.oxygen_role`). NULL means no override — today's
|
|
202
|
+
* behavior for every existing member — and `client` is the restricted role an
|
|
203
|
+
* agency hands its client: read the work, comment on it, decide the approvals
|
|
204
|
+
* assigned to them, and nothing else.
|
|
205
|
+
*
|
|
206
|
+
* Deliberately not a Clerk custom role: those need dashboard configuration, and
|
|
207
|
+
* repo doctrine requires asking a human before touching Clerk. Keep this list in
|
|
208
|
+
* step with the `organization_memberships_oxygen_role_check` constraint in
|
|
209
|
+
* control migration 0095 — the CHECK is what stops a typo from creating a role
|
|
210
|
+
* nothing enforces.
|
|
211
|
+
*/
|
|
212
|
+
export declare const COLLAB_MEMBER_ROLES: readonly ["client"];
|
|
213
|
+
export type CollabMemberRole = (typeof COLLAB_MEMBER_ROLES)[number];
|
|
214
|
+
export declare function isCollabMemberRole(value: unknown): value is CollabMemberRole;
|
|
215
|
+
/**
|
|
216
|
+
* The CLERK workspace roles that carry admin authority (control DB
|
|
217
|
+
* `organization_memberships.role`). Both spellings of each: Clerk writes the
|
|
218
|
+
* `org:`-prefixed form, older rows carry the bare one.
|
|
219
|
+
*
|
|
220
|
+
* The overlay above and this list are the two halves of one question — "may this
|
|
221
|
+
* member do X" — and a Clerk admin's role OVERRIDES the overlay, which is why
|
|
222
|
+
* granting `client` to one is refused. Keeping the vocabulary here means the
|
|
223
|
+
* collaboration lib can ask without importing the billing graph, and there is
|
|
224
|
+
* one list to change if Clerk ever gains another admin-ish role.
|
|
225
|
+
*/
|
|
226
|
+
export declare const WORKSPACE_ADMIN_ROLES: readonly ["admin", "owner", "org:admin", "org:owner"];
|
|
227
|
+
export declare function isWorkspaceAdminRole(role: string): boolean;
|
|
228
|
+
export {};
|