@oxygen-agent/cli 1.627.3 → 1.632.1
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/index.js +224 -8
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +24 -3
- package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +22 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +85 -0
- 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/dist/workflow-trigger-label.d.ts +63 -0
- package/node_modules/@oxygen/shared/dist/workflow-trigger-label.js +85 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +128 -48
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -667,6 +667,10 @@ function writeMaxCreditsHint(error) {
|
|
|
667
667
|
return;
|
|
668
668
|
}
|
|
669
669
|
case "approval_required": {
|
|
670
|
+
if (error.message.startsWith("Public replies require --approved")) {
|
|
671
|
+
process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
670
674
|
// Free-but-irreversible gates (tables/columns delete --now) approve with
|
|
671
675
|
// --yes and spend nothing — the spend-flag hint would name flags the
|
|
672
676
|
// command does not take. The server message is the signal: it spells out
|
|
@@ -2035,6 +2039,30 @@ function buildPublishingAnalyticsPath(base, params) {
|
|
|
2035
2039
|
const suffix = query.toString();
|
|
2036
2040
|
return suffix ? `${base}?${suffix}` : base;
|
|
2037
2041
|
}
|
|
2042
|
+
function buildPublishingCommentsListPath(options) {
|
|
2043
|
+
return buildPublishingAnalyticsPath("/api/cli/publishing/comments", {
|
|
2044
|
+
status: readOption(options.status),
|
|
2045
|
+
post_id: readOption(options.post),
|
|
2046
|
+
assignee: readOption(options.assignee),
|
|
2047
|
+
channel: readOption(options.channel),
|
|
2048
|
+
cursor: readOption(options.cursor),
|
|
2049
|
+
limit: readOption(options.limit),
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
function readPublishingCommentText(text, file, label) {
|
|
2053
|
+
if (readOption(text) && readOption(file)) {
|
|
2054
|
+
throw new OxygenError("conflicting_flags", `Pass either --${label} or --${label}-file, not both.`, {
|
|
2055
|
+
exitCode: 1,
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
const value = readOption(file)
|
|
2059
|
+
? readPublishingTextFile(readOption(file) ?? "", `--${label}-file`)
|
|
2060
|
+
: readOption(text);
|
|
2061
|
+
if (!value?.trim()) {
|
|
2062
|
+
throw new OxygenError("invalid_request", `Pass --${label} or --${label}-file.`, { exitCode: 1 });
|
|
2063
|
+
}
|
|
2064
|
+
return value.trim();
|
|
2065
|
+
}
|
|
2038
2066
|
// `--file` is the whole import: a .csv is posted verbatim as `csv` (the API owns
|
|
2039
2067
|
// the parse, so the CLI and the web importer can never disagree about a quoted
|
|
2040
2068
|
// comma); a .json is posted as `rows`. Dry-run is the DEFAULT — a bare
|
|
@@ -3288,7 +3316,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3288
3316
|
}));
|
|
3289
3317
|
program
|
|
3290
3318
|
.command("publishing")
|
|
3291
|
-
.description("
|
|
3319
|
+
.description("Social publishing, performance, and public-comment operations.")
|
|
3292
3320
|
.addCommand(new Command("mentions")
|
|
3293
3321
|
.description("Resolve LinkedIn identities for a publish-faithful post preview.")
|
|
3294
3322
|
.addCommand(new Command("resolve")
|
|
@@ -3614,11 +3642,123 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3614
3642
|
method: "POST",
|
|
3615
3643
|
body: buildPublishingTagsBody(options),
|
|
3616
3644
|
}));
|
|
3645
|
+
})))
|
|
3646
|
+
.addCommand(new Command("comments")
|
|
3647
|
+
.description("Public comments on posts published through OXYGEN. These are network conversations, not workspace review notes. The local queue targets a six-hour background poll; provider quota/backoff can delay it, and list reports the exact freshness plus empty_queue_is_current. If that field is false, do not conclude there are no comments: wait for next_sync_at. The worker retries automatically; there is no manual retry by design because it could loop against LinkedIn quota. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
|
|
3648
|
+
.addCommand(new Command("list")
|
|
3649
|
+
.description("List the local public-comment work queue, oldest first, with polling freshness/backoff even when empty. Defaults to LinkedIn comments needing a reply. Read-only: no provider call, 0 credits.")
|
|
3650
|
+
.option("--status <status>", "Filter by needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable.")
|
|
3651
|
+
.option("--post <post_id>", "Only comments on one scheduled Publishing post.")
|
|
3652
|
+
.option("--assignee <actor>", "Only one actor id; pass me for your own assignments.")
|
|
3653
|
+
.option("--channel <channel>", "Social channel. Defaults to linkedin.")
|
|
3654
|
+
.option("--cursor <cursor>", "Opaque next_cursor from the previous page.")
|
|
3655
|
+
.option("--limit <n>", "Maximum comments to return. Defaults to 50; maximum 100.")
|
|
3656
|
+
.option("--json", "Print a JSON envelope.")
|
|
3657
|
+
.action(async (options) => {
|
|
3658
|
+
await handleAsyncAction("publishing comments list", options, () => requestOxygen(buildPublishingCommentsListPath(options)));
|
|
3659
|
+
}))
|
|
3660
|
+
.addCommand(new Command("get")
|
|
3661
|
+
.description("Get one stored public comment with its provider thread and immutable reply-action history. Read-only: no provider call, 0 credits.")
|
|
3662
|
+
.argument("<comment_id>", "OXYGEN public comment id.")
|
|
3663
|
+
.option("--json", "Print a JSON envelope.")
|
|
3664
|
+
.action(async (commentId, options) => {
|
|
3665
|
+
await handleAsyncAction("publishing comments get", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}`));
|
|
3666
|
+
}))
|
|
3667
|
+
.addCommand(new Command("update")
|
|
3668
|
+
.description("Save local draft, notes, assignment, or triage state. Nothing is posted publicly and no credits are used.")
|
|
3669
|
+
.argument("<comment_id>", "OXYGEN public comment id.")
|
|
3670
|
+
.option("--draft <text>", "Save a reply draft locally.")
|
|
3671
|
+
.option("--draft-file <path>", "Read the local reply draft from a file.")
|
|
3672
|
+
.option("--notes <text>", "Workspace-only operator notes.")
|
|
3673
|
+
.option("--assignee <actor_id>", "Assign to a workspace actor id; pass an empty string to clear.")
|
|
3674
|
+
.option("--status <status>", "Set needs_reply, draft, resolved, ignored, or spam.")
|
|
3675
|
+
.option("--json", "Print a JSON envelope.")
|
|
3676
|
+
.action(async (commentId, options) => {
|
|
3677
|
+
await handleAsyncAction("publishing comments update", options, () => {
|
|
3678
|
+
const hasDraft = Boolean(readOption(options.draft) || readOption(options.draftFile));
|
|
3679
|
+
if (!hasDraft && options.notes === undefined && options.assignee === undefined && options.status === undefined) {
|
|
3680
|
+
throw new OxygenError("invalid_request", "Pass --draft/--draft-file, --notes, --assignee, or --status.", { exitCode: 1 });
|
|
3681
|
+
}
|
|
3682
|
+
return requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}`, {
|
|
3683
|
+
method: "PATCH",
|
|
3684
|
+
body: {
|
|
3685
|
+
...(hasDraft
|
|
3686
|
+
? { draft_body: readPublishingCommentText(options.draft, options.draftFile, "draft") }
|
|
3687
|
+
: {}),
|
|
3688
|
+
...(options.notes !== undefined ? { notes: options.notes } : {}),
|
|
3689
|
+
...(options.assignee !== undefined
|
|
3690
|
+
? { assignee_actor_id: readOption(options.assignee) ?? null }
|
|
3691
|
+
: {}),
|
|
3692
|
+
...(readOption(options.status) ? { status: readOption(options.status) } : {}),
|
|
3693
|
+
},
|
|
3694
|
+
});
|
|
3695
|
+
});
|
|
3696
|
+
}))
|
|
3697
|
+
.addCommand(new Command("reply")
|
|
3698
|
+
.description("Preview an exact public reply and return its action_id + content_hash. Makes no provider call, posts nothing, and uses 0 credits.")
|
|
3699
|
+
.argument("<comment_id>", "OXYGEN public comment id.")
|
|
3700
|
+
.option("--text <text>", "Reply text to preview.")
|
|
3701
|
+
.option("--text-file <path>", "Read reply text from a file.")
|
|
3702
|
+
.option("--idempotency-key <key>", "Stable key for safely recreating the same preview.")
|
|
3703
|
+
.option("--json", "Print a JSON envelope.")
|
|
3704
|
+
.action(async (commentId, options) => {
|
|
3705
|
+
await handleAsyncAction("publishing comments reply preview", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}/reply-actions`, {
|
|
3706
|
+
method: "POST",
|
|
3707
|
+
body: {
|
|
3708
|
+
body: readPublishingCommentText(options.text, options.textFile, "text"),
|
|
3709
|
+
...(readOption(options.idempotencyKey)
|
|
3710
|
+
? { idempotency_key: readOption(options.idempotencyKey) }
|
|
3711
|
+
: {}),
|
|
3712
|
+
},
|
|
3713
|
+
}));
|
|
3714
|
+
}))
|
|
3715
|
+
.addCommand(new Command("approve")
|
|
3716
|
+
.description("Post one exact previewed reply publicly. A REAL LinkedIn write; refused without --approved and the preview's content hash.")
|
|
3717
|
+
.argument("<action_id>", "Reply action id returned by `publishing comments reply`.")
|
|
3718
|
+
.requiredOption("--content-hash <sha256>", "Exact content_hash returned by the preview.")
|
|
3719
|
+
.option("--approved", "Approve this exact public reply.")
|
|
3720
|
+
.option("--json", "Print a JSON envelope.")
|
|
3721
|
+
.action(async (actionId, options) => {
|
|
3722
|
+
await handleAsyncAction("publishing comments reply approve", options, () => {
|
|
3723
|
+
if (options.approved !== true) {
|
|
3724
|
+
throw new OxygenError("approval_required", "Public replies require --approved after inspecting the exact preview.", { exitCode: 7 });
|
|
3725
|
+
}
|
|
3726
|
+
return requestOxygen(`/api/cli/publishing/comment-replies/${encodeURIComponent(actionId)}/approve`, {
|
|
3727
|
+
method: "POST",
|
|
3728
|
+
body: { content_hash: options.contentHash, approved: true },
|
|
3729
|
+
});
|
|
3730
|
+
});
|
|
3731
|
+
}))
|
|
3732
|
+
.addCommand(new Command("resolve")
|
|
3733
|
+
.description("Resolve, ignore, mark spam, or reopen one local public-comment work item. No provider write, 0 credits.")
|
|
3734
|
+
.argument("<comment_id>", "OXYGEN public comment id.")
|
|
3735
|
+
.option("--resolution <status>", "resolved (default), ignored, spam, or needs_reply.")
|
|
3736
|
+
.option("--json", "Print a JSON envelope.")
|
|
3737
|
+
.action(async (commentId, options) => {
|
|
3738
|
+
await handleAsyncAction("publishing comments resolve", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}/resolve`, {
|
|
3739
|
+
method: "POST",
|
|
3740
|
+
body: { resolution: readOption(options.resolution) ?? "resolved" },
|
|
3741
|
+
}));
|
|
3617
3742
|
})))
|
|
3618
3743
|
.addCommand(new Command("analytics")
|
|
3619
3744
|
.description("Engagement metrics the worker syncs back from the provider. Free reads. LinkedIn exposes reactions, comments, and reshares only — impressions, saves, and sends come back null, never zero.")
|
|
3745
|
+
.addCommand(new Command("summary")
|
|
3746
|
+
.description("Workspace LinkedIn performance for posts published through OXYGEN. Uses each post's latest stored snapshot; unsupported metrics stay null. Read-only: no provider call, 0 credits.")
|
|
3747
|
+
.option("--range <range>", "Publication window: 7d, 30d (default), 90d, or 365d.")
|
|
3748
|
+
.option("--from <date>", "Custom inclusive UTC start date.")
|
|
3749
|
+
.option("--to <date>", "Custom inclusive UTC end date.")
|
|
3750
|
+
.option("--limit <n>", "Top posts to return. Defaults to 10, maximum 50.")
|
|
3751
|
+
.option("--json", "Print a JSON envelope.")
|
|
3752
|
+
.action(async (options) => {
|
|
3753
|
+
await handleAsyncAction("publishing analytics summary", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
|
|
3754
|
+
range: readOption(options.range),
|
|
3755
|
+
from: readOption(options.from),
|
|
3756
|
+
to: readOption(options.to),
|
|
3757
|
+
limit: readOption(options.limit),
|
|
3758
|
+
})));
|
|
3759
|
+
}))
|
|
3620
3760
|
.addCommand(new Command("post")
|
|
3621
|
-
.description("One post's daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number.")
|
|
3761
|
+
.description("One post's stored daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number. Read-only: no provider call, 0 credits.")
|
|
3622
3762
|
.argument("<post_id>", "Scheduled post id.")
|
|
3623
3763
|
.option("--since <date>", "ISO date to start the series from.")
|
|
3624
3764
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -3626,7 +3766,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3626
3766
|
await handleAsyncAction("publishing analytics post", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) })));
|
|
3627
3767
|
}))
|
|
3628
3768
|
.addCommand(new Command("account")
|
|
3629
|
-
.description("
|
|
3769
|
+
.description("Stored per-account metric series for connected publishing accounts. Read-only: no provider call, 0 credits.")
|
|
3630
3770
|
.option("--provider <provider>", "Provider to read. Defaults to linkedin.")
|
|
3631
3771
|
.option("--since <date>", "ISO date to start the series from.")
|
|
3632
3772
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -4845,6 +4985,82 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4845
4985
|
mode: options.live ? "live" : "dry_run",
|
|
4846
4986
|
},
|
|
4847
4987
|
}));
|
|
4988
|
+
}))
|
|
4989
|
+
.addCommand(new Command("relations")
|
|
4990
|
+
.description("Inspect and manage native Tables-owned relation definitions.")
|
|
4991
|
+
.addCommand(new Command("list")
|
|
4992
|
+
.description("List the native relations visible from one table, including cardinality, target, and active link count.")
|
|
4993
|
+
.argument("<table>", "Table id or slug.")
|
|
4994
|
+
.option("--json", "Print a JSON envelope.")
|
|
4995
|
+
.action(async (table, options) => {
|
|
4996
|
+
await handleAsyncAction("tables relations list", options, () => {
|
|
4997
|
+
const params = new URLSearchParams({ table });
|
|
4998
|
+
return requestOxygen(`/api/cli/tables/relations?${params.toString()}`);
|
|
4999
|
+
});
|
|
5000
|
+
}))
|
|
5001
|
+
.addCommand(new Command("update")
|
|
5002
|
+
.description("Rename either side of a relation or change its cardinality. Endpoints and slugs stay immutable. Previews by default.")
|
|
5003
|
+
.argument("<table>", "Table id or slug.")
|
|
5004
|
+
.argument("<relation>", "Relation slug as seen from this table.")
|
|
5005
|
+
.option("--display-name <name>", "New display name on this table.")
|
|
5006
|
+
.option("--inverse-display-name <name>", "New display name on the other table.")
|
|
5007
|
+
.option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many from this table's perspective.")
|
|
5008
|
+
.option("--approved", "Apply after inspecting the preview.")
|
|
5009
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
5010
|
+
.option("--json", "Print a JSON envelope.")
|
|
5011
|
+
.action(async (table, relation, options) => {
|
|
5012
|
+
await handleAsyncAction("tables relations update", options, () => requestOxygen("/api/cli/tables/relations", {
|
|
5013
|
+
method: "PATCH",
|
|
5014
|
+
body: {
|
|
5015
|
+
table,
|
|
5016
|
+
relation,
|
|
5017
|
+
...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
|
|
5018
|
+
...(readOption(options.inverseDisplayName)
|
|
5019
|
+
? { inverse_display_name: readOption(options.inverseDisplayName) }
|
|
5020
|
+
: {}),
|
|
5021
|
+
...(readOption(options.cardinality) ? { cardinality: readOption(options.cardinality) } : {}),
|
|
5022
|
+
mode: options.approved && !options.dryRun ? "live" : "dry_run",
|
|
5023
|
+
},
|
|
5024
|
+
}));
|
|
5025
|
+
}))
|
|
5026
|
+
.addCommand(new Command("archive")
|
|
5027
|
+
.description("Archive a native relation, its active links, and both relation columns while preserving history. Previews by default.")
|
|
5028
|
+
.argument("<table>", "Table id or slug.")
|
|
5029
|
+
.argument("<relation>", "Relation slug as seen from this table.")
|
|
5030
|
+
.option("--approved", "Apply after inspecting the preview.")
|
|
5031
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
5032
|
+
.option("--json", "Print a JSON envelope.")
|
|
5033
|
+
.action(async (table, relation, options) => {
|
|
5034
|
+
await handleAsyncAction("tables relations archive", options, () => requestOxygen("/api/cli/tables/relations", {
|
|
5035
|
+
method: "DELETE",
|
|
5036
|
+
body: {
|
|
5037
|
+
table,
|
|
5038
|
+
relation,
|
|
5039
|
+
mode: options.approved && !options.dryRun ? "live" : "dry_run",
|
|
5040
|
+
},
|
|
5041
|
+
}));
|
|
5042
|
+
})))
|
|
5043
|
+
.addCommand(new Command("unlink")
|
|
5044
|
+
.description("Unlink one exact pair of rows by archiving their relation edge. The relation definition and both rows remain. Previews by default.")
|
|
5045
|
+
.argument("<table>", "Source table id or slug.")
|
|
5046
|
+
.argument("<row_id>", "Source row id.")
|
|
5047
|
+
.requiredOption("--relation <slug>", "Relation slug as seen from the source table.")
|
|
5048
|
+
.requiredOption("--target-row-id <row_id>", "Linked row id to remove.")
|
|
5049
|
+
.option("--approved", "Apply after inspecting the preview.")
|
|
5050
|
+
.option("--live", "Alias for --approved.")
|
|
5051
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
5052
|
+
.option("--json", "Print a JSON envelope.")
|
|
5053
|
+
.action(async (table, rowId, options) => {
|
|
5054
|
+
await handleAsyncAction("tables unlink", options, () => requestOxygen("/api/cli/tables/relations/unlink", {
|
|
5055
|
+
method: "POST",
|
|
5056
|
+
body: {
|
|
5057
|
+
table,
|
|
5058
|
+
row_id: rowId,
|
|
5059
|
+
relation: options.relation,
|
|
5060
|
+
target_row_id: options.targetRowId,
|
|
5061
|
+
mode: (options.approved || options.live) && !options.dryRun ? "live" : "dry_run",
|
|
5062
|
+
},
|
|
5063
|
+
}));
|
|
4848
5064
|
}))
|
|
4849
5065
|
.addCommand(new Command("link")
|
|
4850
5066
|
.description("Link two tables: Oxygen finds the column that joins them, works out the direction, shows you how many rows will match, and on approval links every row. Free. Previews by default; --approved queues a background run and records each row's outcome in a system link_status_<relation> column. Wait for that run with `oxygen table-runs wait <run_id>`. (Also links one row to one row — see --relation.)")
|
|
@@ -9837,9 +10053,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9837
10053
|
});
|
|
9838
10054
|
})));
|
|
9839
10055
|
program.addCommand(new Command("posts")
|
|
9840
|
-
.description("Read and publish LinkedIn posts through a connected account. `get`
|
|
10056
|
+
.description("Read and publish LinkedIn posts through a connected account. `get`, `comments`, and `reactions` are LIVE provider reads: they use 0 Oxygen credits but consume the sender's daily account-read allowance. Do not use them when a task forbids provider calls; use `publishing comments list` for the local cross-post queue. `create` publishes a real post (approval-gated). Every command here addresses ONE post you already have an id for — Oxygen cannot enumerate an account's own LinkedIn history, so analytics only cover posts Oxygen itself published (`oxygen publishing ...`).")
|
|
9841
10057
|
.addCommand(new Command("get")
|
|
9842
|
-
.description("
|
|
10058
|
+
.description("LIVE provider read of one LinkedIn post. Uses 0 Oxygen credits but consumes the sender's metered account-read allowance; do not run it when the task forbids provider calls. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). This does not populate or refresh the durable `publishing comments` queue.")
|
|
9843
10059
|
.requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id. There is no way to LIST your own posts: take the id from the post's LinkedIn URL, or from a post you scheduled (`oxygen publishing posts get <post_id>` → provider_post_id).")
|
|
9844
10060
|
.option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
9845
10061
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -9854,7 +10070,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9854
10070
|
});
|
|
9855
10071
|
}))
|
|
9856
10072
|
.addCommand(new Command("comments")
|
|
9857
|
-
.description("
|
|
10073
|
+
.description("Read one post's comments live from LinkedIn (or one comment's replies with --comment-id). This consumes the sender's metered account-read allowance, uses 0 Oxygen credits, and does not populate the durable queue. For the zero-provider-call cross-post work queue, use `publishing comments list`. --post MUST be the composite social_id from `posts get`, not the activity URN.")
|
|
9858
10074
|
.requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
|
|
9859
10075
|
.option("--comment-id <id>", "List replies to this comment instead of top-level comments.")
|
|
9860
10076
|
.option("--sort-by <sort>", "Comment sort order (provider-defined, e.g. recent|relevant).")
|
|
@@ -10495,7 +10711,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10495
10711
|
});
|
|
10496
10712
|
})));
|
|
10497
10713
|
program.addCommand(new Command("inbox")
|
|
10498
|
-
.description("Unified inbox (unibox): email + LinkedIn + WhatsApp conversations in one stream (--channel all), or a single channel. Scan, read threads, and reply.")
|
|
10714
|
+
.description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (--channel all), or a single channel. Public comments on OXYGEN-published posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
|
|
10499
10715
|
.addCommand(new Command("list")
|
|
10500
10716
|
.description("List conversations newest first. --channel all merges email + LinkedIn + WhatsApp into one stream (narrow it with --channels); --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
|
|
10501
10717
|
.option("--channel <channel>", "Inbox channel: all (merged), linkedin (default), whatsapp, or email.")
|
|
@@ -12778,7 +12994,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12778
12994
|
.addCommand(new Command("warmup")
|
|
12779
12995
|
.description("Mailbox warmup for the sending pool. Oxygen's native warmup runs on ONE rail: TrulyInbox, managed and credit-billed per warming-inbox-month. Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. (Instantly and Warmforge remain BYOK integrations elsewhere in Oxygen; they are no longer warmup rails.)")
|
|
12780
12996
|
.addCommand(new Command("enable")
|
|
12781
|
-
.description("Enable warmup for sending mailboxes and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is enrolled or billed. Execute by echoing its --plan and --max-credits with --approved.")
|
|
12997
|
+
.description("Enable warmup for sending mailboxes and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is enrolled or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved.")
|
|
12782
12998
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
|
|
12783
12999
|
.option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
|
|
12784
13000
|
.option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
|
|
@@ -184,11 +184,11 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
184
184
|
notFor: "One-to-one conversations, delivery scheduling, or generic public LinkedIn provider search.",
|
|
185
185
|
execution: "Read or create the hosted Post artifact; Publishing owns scheduled delivery.",
|
|
186
186
|
posture: "workspace_write",
|
|
187
|
-
gatewayTools: ["
|
|
188
|
-
gatewayCommands: ["
|
|
187
|
+
gatewayTools: ["oxygen_publishing_comments", "oxygen_publishing_analytics", "oxygen_posts_get", "oxygen_posts_create"],
|
|
188
|
+
gatewayCommands: ["publishing comments list", "publishing analytics summary", "posts get", "posts create"],
|
|
189
189
|
skills: ["oxygen-linkedin-marketing"],
|
|
190
190
|
endpointSections: [],
|
|
191
|
-
intentTerms: ["post artifact", "social post", "post comments", "post reactions", "post performance", "broadcast content"],
|
|
191
|
+
intentTerms: ["post artifact", "social post", "post comments", "public comments", "comments needing reply", "post reactions", "post performance", "posting analytics", "broadcast content"],
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
id: "signals",
|
|
@@ -473,6 +473,8 @@ function normalizeIntent(query) {
|
|
|
473
473
|
function explicitCapabilityIntent(query) {
|
|
474
474
|
if (isHostedWorkflowIntent(query))
|
|
475
475
|
return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
|
|
476
|
+
if (isOwnedPostCommentIntent(query))
|
|
477
|
+
return ROUTE_BY_PRIMITIVE.get("posts") ?? null;
|
|
476
478
|
// A deferred acquisition motion starts with the public-data owner. This keeps
|
|
477
479
|
// "find/qualify now, contact later" from skipping straight to the final write
|
|
478
480
|
// step; the recommendation still hands the eventual initiation to Sequences.
|
|
@@ -550,6 +552,17 @@ function highestScoringRoute(query) {
|
|
|
550
552
|
return best?.card ?? null;
|
|
551
553
|
}
|
|
552
554
|
function recommendationsFor(card, query) {
|
|
555
|
+
if (card.primitive === "posts" && isOwnedPostCommentIntent(query)) {
|
|
556
|
+
return {
|
|
557
|
+
tools: ["oxygen_publishing_comments", "oxygen_publishing_analytics"],
|
|
558
|
+
commands: [
|
|
559
|
+
"publishing comments list",
|
|
560
|
+
"publishing comments get",
|
|
561
|
+
"publishing comments reply",
|
|
562
|
+
"publishing analytics summary",
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
553
566
|
if (card.id === "linkedin-public-research" && isPublicLinkedInEngagerHarvest(query)) {
|
|
554
567
|
const motion = isLinkedInEngagerMotion(query);
|
|
555
568
|
return {
|
|
@@ -730,6 +743,14 @@ function isNetNewLinkedInInitiation(query) {
|
|
|
730
743
|
return false;
|
|
731
744
|
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);
|
|
732
745
|
}
|
|
746
|
+
function isOwnedPostCommentIntent(query) {
|
|
747
|
+
if (!/\bcomments?\b/.test(query) || !/\b(linkedin|posts?|publishing)\b/.test(query))
|
|
748
|
+
return false;
|
|
749
|
+
const owned = /\b(my|our|own)\b|\boxygen[ -]published\b|\bpublished through oxygen\b/.test(query);
|
|
750
|
+
const operatorWork = /\b(needs?|needing|awaiting)\b.{0,20}\b(response|reply|answer)\b/.test(query)
|
|
751
|
+
|| /\b(reply|respond|answer|triage|queue)\b/.test(query);
|
|
752
|
+
return owned || operatorWork;
|
|
753
|
+
}
|
|
733
754
|
function isHostedWorkflowIntent(query) {
|
|
734
755
|
const strongWorkflowLanguage = /\b(workflow|automation|automate|automatically|webhook|cron|event trigger|branching?|retr(?:y|ies)|orchestrat(?:e|ion)|scheduled job|deterministic graph)\b/.test(query);
|
|
735
756
|
const cadenceLanguage = /\b(sequence|campaign|cadence|day\s*\d+|multi[ -]step|rotate senders?|sender rotation|stop on reply|until (?:they )?reply)\b/.test(query);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const COPILOT_TURN_TIMEOUT_CODE = "copilot_turn_timeout";
|
|
2
|
+
export declare const COPILOT_TURN_TIMEOUT_MESSAGE = "This Copilot request timed out and stopped. Any completed actions are still saved\u2014review this session before trying again.";
|
|
3
|
+
export type CustomerFacingCopilotError = {
|
|
4
|
+
code: string | null;
|
|
5
|
+
message: string | null;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Replace an internal worker deadline with the stable Copilot timeout contract.
|
|
9
|
+
* The message-pattern fallback heals rows written before the worker error code
|
|
10
|
+
* was persisted consistently. Other errors pass through byte-for-byte.
|
|
11
|
+
*/
|
|
12
|
+
export declare function customerFacingCopilotError(input: {
|
|
13
|
+
code?: string | null;
|
|
14
|
+
message?: string | null;
|
|
15
|
+
}): CustomerFacingCopilotError;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Customer-facing Workspace Copilot error policy. Worker supervision details
|
|
2
|
+
// belong in operational telemetry, never in the conversation timeline or turn
|
|
3
|
+
// row every Control surface renders. Keep this mapping shared so new failures
|
|
4
|
+
// and already-persisted failures serialize identically across web, CLI, and MCP.
|
|
5
|
+
export const COPILOT_TURN_TIMEOUT_CODE = "copilot_turn_timeout";
|
|
6
|
+
export const COPILOT_TURN_TIMEOUT_MESSAGE = "This Copilot request timed out and stopped. Any completed actions are still saved—review this session before trying again.";
|
|
7
|
+
const WORKER_STEP_TIMEOUT_MESSAGE = /\bWorker step '[^']+' exceeded \d+ms deadline\.?/i;
|
|
8
|
+
/**
|
|
9
|
+
* Replace an internal worker deadline with the stable Copilot timeout contract.
|
|
10
|
+
* The message-pattern fallback heals rows written before the worker error code
|
|
11
|
+
* was persisted consistently. Other errors pass through byte-for-byte.
|
|
12
|
+
*/
|
|
13
|
+
export function customerFacingCopilotError(input) {
|
|
14
|
+
const code = input.code ?? null;
|
|
15
|
+
const message = input.message ?? null;
|
|
16
|
+
const isTimeout = code === "worker_step_timeout" ||
|
|
17
|
+
code === COPILOT_TURN_TIMEOUT_CODE ||
|
|
18
|
+
(message !== null && WORKER_STEP_TIMEOUT_MESSAGE.test(message));
|
|
19
|
+
return isTimeout
|
|
20
|
+
? { code: COPILOT_TURN_TIMEOUT_CODE, message: COPILOT_TURN_TIMEOUT_MESSAGE }
|
|
21
|
+
: { code, message };
|
|
22
|
+
}
|
|
@@ -14,6 +14,7 @@ export * from "./cli-result.js";
|
|
|
14
14
|
export * from "./crm-reply-events.js";
|
|
15
15
|
export * from "./crm-activity-events.js";
|
|
16
16
|
export * from "./column-types.js";
|
|
17
|
+
export * from "./copilot-errors.js";
|
|
17
18
|
export * from "./copilot-journeys.js";
|
|
18
19
|
export * from "./credit-guidance.js";
|
|
19
20
|
export * from "./directory.js";
|
|
@@ -47,6 +48,7 @@ export * from "./log.js";
|
|
|
47
48
|
export { sanitizeLogFields } from "./redaction.js";
|
|
48
49
|
export * from "./provider-request-outcomes.js";
|
|
49
50
|
export * from "./schedule-label.js";
|
|
51
|
+
export * from "./social-capabilities.js";
|
|
50
52
|
export * from "./signup-lead-deliveries.js";
|
|
51
53
|
export * from "./sql-error.js";
|
|
52
54
|
export * from "./tags.js";
|
|
@@ -56,6 +58,7 @@ export * from "./timing.js";
|
|
|
56
58
|
export * from "./type-guards.js";
|
|
57
59
|
export * from "./worker-failures-queue.js";
|
|
58
60
|
export * from "./workflow-mcp-tools.js";
|
|
61
|
+
export * from "./workflow-trigger-label.js";
|
|
59
62
|
export * from "./workspace-event-catalog.js";
|
|
60
63
|
export * from "./workspace-agents.js";
|
|
61
64
|
export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
|
|
@@ -14,6 +14,7 @@ export * from "./cli-result.js";
|
|
|
14
14
|
export * from "./crm-reply-events.js";
|
|
15
15
|
export * from "./crm-activity-events.js";
|
|
16
16
|
export * from "./column-types.js";
|
|
17
|
+
export * from "./copilot-errors.js";
|
|
17
18
|
export * from "./copilot-journeys.js";
|
|
18
19
|
export * from "./credit-guidance.js";
|
|
19
20
|
export * from "./directory.js";
|
|
@@ -51,6 +52,7 @@ export * from "./log.js";
|
|
|
51
52
|
export { sanitizeLogFields } from "./redaction.js";
|
|
52
53
|
export * from "./provider-request-outcomes.js";
|
|
53
54
|
export * from "./schedule-label.js";
|
|
55
|
+
export * from "./social-capabilities.js";
|
|
54
56
|
export * from "./signup-lead-deliveries.js";
|
|
55
57
|
export * from "./sql-error.js";
|
|
56
58
|
export * from "./tags.js";
|
|
@@ -60,6 +62,7 @@ export * from "./timing.js";
|
|
|
60
62
|
export * from "./type-guards.js";
|
|
61
63
|
export * from "./worker-failures-queue.js";
|
|
62
64
|
export * from "./workflow-mcp-tools.js";
|
|
65
|
+
export * from "./workflow-trigger-label.js";
|
|
63
66
|
export * from "./workspace-event-catalog.js";
|
|
64
67
|
export * from "./workspace-agents.js";
|
|
65
68
|
// Maximum rows a single row-loop write (insert/upsert/preview) may process. The
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social channel capability registry.
|
|
3
|
+
*
|
|
4
|
+
* Channel, provider rail, and operation support are deliberately independent.
|
|
5
|
+
* LinkedIn is the only registered channel today, but callers resolve capabilities
|
|
6
|
+
* through this contract instead of branching on `channel === "linkedin"`. Adding a
|
|
7
|
+
* channel later means registering a real adapter profile, not adding placeholder UI.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SOCIAL_CHANNELS: readonly ["linkedin"];
|
|
10
|
+
export type SocialChannel = (typeof SOCIAL_CHANNELS)[number];
|
|
11
|
+
export declare const SOCIAL_PROVIDER_RAILS: readonly ["unipile"];
|
|
12
|
+
export type SocialProviderRail = (typeof SOCIAL_PROVIDER_RAILS)[number];
|
|
13
|
+
export declare const SOCIAL_OPERATIONS: readonly ["list_owned_posts", "read_post_metrics", "list_comments", "reply_to_comment"];
|
|
14
|
+
export type SocialOperation = (typeof SOCIAL_OPERATIONS)[number];
|
|
15
|
+
export declare const SOCIAL_POST_METRICS: readonly ["impressions", "reactions", "comments", "reposts", "saves", "sends", "clicks", "video_views"];
|
|
16
|
+
export type SocialPostMetric = (typeof SOCIAL_POST_METRICS)[number];
|
|
17
|
+
export type SocialCapabilityStatus = "available" | "limited" | "unavailable";
|
|
18
|
+
export type SocialOperationCapability = {
|
|
19
|
+
status: SocialCapabilityStatus;
|
|
20
|
+
/** Local durable state or a provider operation. */
|
|
21
|
+
source: "oxygen_store" | "provider";
|
|
22
|
+
provider_operation: string | null;
|
|
23
|
+
external_write: boolean;
|
|
24
|
+
limitation: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type SocialMetricCapability = {
|
|
27
|
+
status: "available" | "unavailable";
|
|
28
|
+
provider_field: string | null;
|
|
29
|
+
reason: string | null;
|
|
30
|
+
};
|
|
31
|
+
export type SocialCapabilityProfile = {
|
|
32
|
+
version: 1;
|
|
33
|
+
channel: SocialChannel;
|
|
34
|
+
provider_rail: SocialProviderRail;
|
|
35
|
+
operations: Record<SocialOperation, SocialOperationCapability>;
|
|
36
|
+
metrics: Record<SocialPostMetric, SocialMetricCapability>;
|
|
37
|
+
ingestion: {
|
|
38
|
+
polling: boolean;
|
|
39
|
+
webhooks: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export declare function listSocialCapabilityProfiles(): readonly SocialCapabilityProfile[];
|
|
43
|
+
export declare function getSocialCapabilityProfile(channel: string, providerRail: string): SocialCapabilityProfile | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social channel capability registry.
|
|
3
|
+
*
|
|
4
|
+
* Channel, provider rail, and operation support are deliberately independent.
|
|
5
|
+
* LinkedIn is the only registered channel today, but callers resolve capabilities
|
|
6
|
+
* through this contract instead of branching on `channel === "linkedin"`. Adding a
|
|
7
|
+
* channel later means registering a real adapter profile, not adding placeholder UI.
|
|
8
|
+
*/
|
|
9
|
+
export const SOCIAL_CHANNELS = ["linkedin"];
|
|
10
|
+
export const SOCIAL_PROVIDER_RAILS = ["unipile"];
|
|
11
|
+
export const SOCIAL_OPERATIONS = [
|
|
12
|
+
"list_owned_posts",
|
|
13
|
+
"read_post_metrics",
|
|
14
|
+
"list_comments",
|
|
15
|
+
"reply_to_comment",
|
|
16
|
+
];
|
|
17
|
+
export const SOCIAL_POST_METRICS = [
|
|
18
|
+
"impressions",
|
|
19
|
+
"reactions",
|
|
20
|
+
"comments",
|
|
21
|
+
"reposts",
|
|
22
|
+
"saves",
|
|
23
|
+
"sends",
|
|
24
|
+
"clicks",
|
|
25
|
+
"video_views",
|
|
26
|
+
];
|
|
27
|
+
const NOT_EXPOSED_BY_UNIPILE = "not_exposed_by_current_provider_rail";
|
|
28
|
+
const LINKEDIN_UNIPILE_CAPABILITIES = {
|
|
29
|
+
version: 1,
|
|
30
|
+
channel: "linkedin",
|
|
31
|
+
provider_rail: "unipile",
|
|
32
|
+
operations: {
|
|
33
|
+
list_owned_posts: {
|
|
34
|
+
status: "limited",
|
|
35
|
+
source: "oxygen_store",
|
|
36
|
+
provider_operation: null,
|
|
37
|
+
external_write: false,
|
|
38
|
+
limitation: "oxygen_published_posts_only",
|
|
39
|
+
},
|
|
40
|
+
read_post_metrics: {
|
|
41
|
+
status: "limited",
|
|
42
|
+
source: "provider",
|
|
43
|
+
provider_operation: "linkedin.posts_get",
|
|
44
|
+
external_write: false,
|
|
45
|
+
limitation: "coarse_public_counters_only",
|
|
46
|
+
},
|
|
47
|
+
list_comments: {
|
|
48
|
+
status: "available",
|
|
49
|
+
source: "provider",
|
|
50
|
+
provider_operation: "linkedin.posts_comments_list",
|
|
51
|
+
external_write: false,
|
|
52
|
+
limitation: null,
|
|
53
|
+
},
|
|
54
|
+
reply_to_comment: {
|
|
55
|
+
status: "available",
|
|
56
|
+
source: "provider",
|
|
57
|
+
provider_operation: "linkedin.posts_comment_create",
|
|
58
|
+
external_write: true,
|
|
59
|
+
limitation: null,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
metrics: {
|
|
63
|
+
impressions: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
|
|
64
|
+
reactions: { status: "available", provider_field: "reaction_count", reason: null },
|
|
65
|
+
comments: { status: "available", provider_field: "comment_count", reason: null },
|
|
66
|
+
reposts: { status: "available", provider_field: "repost_count", reason: null },
|
|
67
|
+
saves: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
|
|
68
|
+
sends: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
|
|
69
|
+
clicks: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
|
|
70
|
+
video_views: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
|
|
71
|
+
},
|
|
72
|
+
ingestion: {
|
|
73
|
+
polling: true,
|
|
74
|
+
webhooks: false,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const SOCIAL_CAPABILITY_PROFILES = [
|
|
78
|
+
LINKEDIN_UNIPILE_CAPABILITIES,
|
|
79
|
+
];
|
|
80
|
+
export function listSocialCapabilityProfiles() {
|
|
81
|
+
return SOCIAL_CAPABILITY_PROFILES;
|
|
82
|
+
}
|
|
83
|
+
export function getSocialCapabilityProfile(channel, providerRail) {
|
|
84
|
+
return SOCIAL_CAPABILITY_PROFILES.find((profile) => profile.channel === channel && profile.provider_rail === providerRail) ?? null;
|
|
85
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.632.1";
|
|
2
2
|
// The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
|
|
3
3
|
// operational route. Raising it hard-rejects every older CLI from the entire
|
|
4
4
|
// product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type WorkspaceEventCatalogEntry } from "./workspace-event-catalog.js";
|
|
2
|
+
/**
|
|
3
|
+
* A workflow event trigger's filter, loosely typed so callers can pass either
|
|
4
|
+
* the canonical `WorkflowEventFilter` (packages/workflows) or a trigger row's
|
|
5
|
+
* persisted `metadata.filters` JSON without this module depending on either
|
|
6
|
+
* shape's owning package.
|
|
7
|
+
*/
|
|
8
|
+
export type WorkflowTriggerFilterLike = {
|
|
9
|
+
path: string;
|
|
10
|
+
op: string;
|
|
11
|
+
value?: unknown;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Title-cased trigger-type label ("API", "Webhook", "Cron", "Event") so a
|
|
15
|
+
* trigger reads the same word on every surface that summarizes it. Falls back
|
|
16
|
+
* to a generic capitalization for a trigger type this list doesn't know about
|
|
17
|
+
* yet, never to the raw lowercase identifier — the bug this module fixes was
|
|
18
|
+
* exactly that fallback leaking "event" next to three title-cased siblings.
|
|
19
|
+
*/
|
|
20
|
+
export declare function workflowTriggerTypeLabel(type: string | null | undefined): string;
|
|
21
|
+
/**
|
|
22
|
+
* Order-independent identity for a filter set.
|
|
23
|
+
*
|
|
24
|
+
* MIRRORED by `normalizedFilterSignature` in the visual editor's picker
|
|
25
|
+
* (apps/web/src/lib/workflow-builder/workspace-event-option.ts), which cannot
|
|
26
|
+
* import this one: that module reaches a client component, and client
|
|
27
|
+
* components must not pull the @oxygen/shared barrel into the page bundle.
|
|
28
|
+
* Both answer the same question — "are these the same preset?" — so a drift
|
|
29
|
+
* would let the editor treat a preset as selected while the list labels it as
|
|
30
|
+
* something else. workspace-event-option.test.ts pins them to equal output.
|
|
31
|
+
*/
|
|
32
|
+
export declare function workflowTriggerFilterSignature(filters: readonly WorkflowTriggerFilterLike[] | null | undefined): string;
|
|
33
|
+
/**
|
|
34
|
+
* Match an event trigger's (source, event, filters) against the workspace
|
|
35
|
+
* event catalog. Filters are part of the identity on purpose: ~28 catalog
|
|
36
|
+
* presets share one (source, event) pair (e.g. `sequencer.contact_activity`)
|
|
37
|
+
* and differ ONLY by filters — "fires on a bounce" and "fires on a reply" must
|
|
38
|
+
* resolve to two different entries, never the same one.
|
|
39
|
+
*/
|
|
40
|
+
export declare function matchWorkspaceEventCatalogEntry(input: {
|
|
41
|
+
source: string;
|
|
42
|
+
event: string;
|
|
43
|
+
filters?: readonly WorkflowTriggerFilterLike[] | null | undefined;
|
|
44
|
+
}): WorkspaceEventCatalogEntry | null;
|
|
45
|
+
export type WorkflowEventTriggerLabel = {
|
|
46
|
+
/** The catalog's human label, or the raw "source · event" identifier when unmatched. Never blank. */
|
|
47
|
+
label: string;
|
|
48
|
+
/** True when a catalog entry matched — callers use this to style a human label differently from a raw identifier. */
|
|
49
|
+
matched: boolean;
|
|
50
|
+
/** The matched catalog entry's group (e.g. "Sequencer"), or null when unmatched. */
|
|
51
|
+
group: string | null;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Human label for an event trigger, resolved against the workspace event
|
|
55
|
+
* catalog. Falls back to the raw "source · event" identifier — never to a
|
|
56
|
+
* blank string — when no catalog entry matches: a generic integration
|
|
57
|
+
* subscription event, or a preset that predates the catalog.
|
|
58
|
+
*/
|
|
59
|
+
export declare function workflowEventTriggerLabel(input: {
|
|
60
|
+
source: string | null | undefined;
|
|
61
|
+
event: string | null | undefined;
|
|
62
|
+
filters?: readonly WorkflowTriggerFilterLike[] | null | undefined;
|
|
63
|
+
}): WorkflowEventTriggerLabel;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Human-facing trigger labels shared by every surface that lists workflows
|
|
2
|
+
// OUTSIDE the visual editor: the web workflows table
|
|
3
|
+
// (apps/web/src/components/workflows/workflows-table.tsx) and the MCP
|
|
4
|
+
// `oxygen.workflow-overview` widget (packages/mcp-server/src/widgets/workflow-overview.ts).
|
|
5
|
+
// Both render a trigger as a one-line summary and need the same two answers:
|
|
6
|
+
// what does this TYPE of trigger call itself ("Webhook", not "webhook"), and —
|
|
7
|
+
// for an event trigger — what does the workspace event catalog call this exact
|
|
8
|
+
// (source, event, filters) combination.
|
|
9
|
+
//
|
|
10
|
+
// Dependency-free on purpose, like schedule-label.ts: consumed by web, CLI, and
|
|
11
|
+
// the MCP widgets (packages/mcp-server depends only on @oxygen/shared).
|
|
12
|
+
import { WORKSPACE_EVENT_CATALOG } from "./workspace-event-catalog.js";
|
|
13
|
+
const WORKFLOW_TRIGGER_TYPE_LABELS = {
|
|
14
|
+
api: "API",
|
|
15
|
+
webhook: "Webhook",
|
|
16
|
+
cron: "Cron",
|
|
17
|
+
event: "Event",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Title-cased trigger-type label ("API", "Webhook", "Cron", "Event") so a
|
|
21
|
+
* trigger reads the same word on every surface that summarizes it. Falls back
|
|
22
|
+
* to a generic capitalization for a trigger type this list doesn't know about
|
|
23
|
+
* yet, never to the raw lowercase identifier — the bug this module fixes was
|
|
24
|
+
* exactly that fallback leaking "event" next to three title-cased siblings.
|
|
25
|
+
*/
|
|
26
|
+
export function workflowTriggerTypeLabel(type) {
|
|
27
|
+
if (!type)
|
|
28
|
+
return "—";
|
|
29
|
+
return WORKFLOW_TRIGGER_TYPE_LABELS[type] ?? `${type.charAt(0).toUpperCase()}${type.slice(1)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Order-independent identity for a filter set.
|
|
33
|
+
*
|
|
34
|
+
* MIRRORED by `normalizedFilterSignature` in the visual editor's picker
|
|
35
|
+
* (apps/web/src/lib/workflow-builder/workspace-event-option.ts), which cannot
|
|
36
|
+
* import this one: that module reaches a client component, and client
|
|
37
|
+
* components must not pull the @oxygen/shared barrel into the page bundle.
|
|
38
|
+
* Both answer the same question — "are these the same preset?" — so a drift
|
|
39
|
+
* would let the editor treat a preset as selected while the list labels it as
|
|
40
|
+
* something else. workspace-event-option.test.ts pins them to equal output.
|
|
41
|
+
*/
|
|
42
|
+
export function workflowTriggerFilterSignature(filters) {
|
|
43
|
+
return (filters ?? [])
|
|
44
|
+
.map((filter) => JSON.stringify({
|
|
45
|
+
path: filter.path,
|
|
46
|
+
op: filter.op,
|
|
47
|
+
...(filter.value !== undefined ? { value: filter.value } : {}),
|
|
48
|
+
}))
|
|
49
|
+
.sort()
|
|
50
|
+
.join("|");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Match an event trigger's (source, event, filters) against the workspace
|
|
54
|
+
* event catalog. Filters are part of the identity on purpose: ~28 catalog
|
|
55
|
+
* presets share one (source, event) pair (e.g. `sequencer.contact_activity`)
|
|
56
|
+
* and differ ONLY by filters — "fires on a bounce" and "fires on a reply" must
|
|
57
|
+
* resolve to two different entries, never the same one.
|
|
58
|
+
*/
|
|
59
|
+
export function matchWorkspaceEventCatalogEntry(input) {
|
|
60
|
+
const signature = workflowTriggerFilterSignature(input.filters);
|
|
61
|
+
return WORKSPACE_EVENT_CATALOG.find((entry) => entry.source === input.source
|
|
62
|
+
&& entry.event === input.event
|
|
63
|
+
&& workflowTriggerFilterSignature(entry.filters) === signature) ?? null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Human label for an event trigger, resolved against the workspace event
|
|
67
|
+
* catalog. Falls back to the raw "source · event" identifier — never to a
|
|
68
|
+
* blank string — when no catalog entry matches: a generic integration
|
|
69
|
+
* subscription event, or a preset that predates the catalog.
|
|
70
|
+
*/
|
|
71
|
+
export function workflowEventTriggerLabel(input) {
|
|
72
|
+
const source = input.source?.trim() || null;
|
|
73
|
+
const event = input.event?.trim() || null;
|
|
74
|
+
const entry = source && event
|
|
75
|
+
? matchWorkspaceEventCatalogEntry({ source, event, filters: input.filters })
|
|
76
|
+
: null;
|
|
77
|
+
const rawIdentifier = source && event
|
|
78
|
+
? `${source} · ${event}`
|
|
79
|
+
: source ?? event ?? "event";
|
|
80
|
+
return {
|
|
81
|
+
label: entry?.label ?? rawIdentifier,
|
|
82
|
+
matched: entry !== null,
|
|
83
|
+
group: entry?.group ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -67,7 +67,11 @@ export function lintWorkflowGraphManifest(value, options = {}) {
|
|
|
67
67
|
if (!isNonEmptyString(value.source_hash)) {
|
|
68
68
|
add("$.source_hash", "missing_source_hash", "Manifest source_hash is required.");
|
|
69
69
|
}
|
|
70
|
-
|
|
70
|
+
// Built once and threaded through every value-ref check below: the second
|
|
71
|
+
// segment of a `steps.` / `loop.` path names a NODE, and that id is the one
|
|
72
|
+
// part of a hand-typed path this linter can always check.
|
|
73
|
+
const scope = nodeScopeIndex(value.nodes);
|
|
74
|
+
const nodesSound = lintNodes(value.nodes, add, scope, options);
|
|
71
75
|
const edgesSound = lintEdges(value.edges, value.nodes, add);
|
|
72
76
|
// Graph-level analysis dereferences every node and edge, so it only runs once
|
|
73
77
|
// the element shapes are known good. Reporting "unreachable" on a manifest
|
|
@@ -104,12 +108,21 @@ function lintWorkflowMetadata(workflow, add) {
|
|
|
104
108
|
add("$.workflow.status", "invalid_workflow_status", "Workflow status must be active or disabled.");
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
function nodeScopeIndex(value) {
|
|
112
|
+
const ids = new Set();
|
|
113
|
+
const loops = new Set();
|
|
114
|
+
for (const node of asArray(value) ?? []) {
|
|
115
|
+
if (!isRecord(node) || !isNonEmptyString(node.id))
|
|
116
|
+
continue;
|
|
117
|
+
ids.add(node.id);
|
|
118
|
+
if (node.kind === "loop")
|
|
119
|
+
loops.add(node.id);
|
|
120
|
+
}
|
|
121
|
+
return { ids, loops };
|
|
122
|
+
}
|
|
110
123
|
/** Returns true when every node is shaped well enough for topology analysis. */
|
|
111
124
|
function lintNodes(// skipcq: JS-R1005
|
|
112
|
-
value, add, options) {
|
|
125
|
+
value, add, scope, options) {
|
|
113
126
|
const nodes = asArray(value);
|
|
114
127
|
if (!nodes || nodes.length === 0) {
|
|
115
128
|
add("$.nodes", "missing_nodes", "At least one workflow node is required.");
|
|
@@ -133,7 +146,7 @@ value, add, options) {
|
|
|
133
146
|
add(`${path}.kind`, "invalid_node_kind", `Node kind must be one of ${[...NODE_KINDS].join(", ")}.`);
|
|
134
147
|
return;
|
|
135
148
|
}
|
|
136
|
-
lintNodeByKind(node, path, add, options);
|
|
149
|
+
lintNodeByKind(node, path, add, scope, options);
|
|
137
150
|
});
|
|
138
151
|
if (triggerCount === 0) {
|
|
139
152
|
add("$.nodes", "missing_trigger_node", "A workflow graph requires exactly one trigger node.");
|
|
@@ -210,21 +223,21 @@ function lintNodeRetry(node, path, add) {
|
|
|
210
223
|
}
|
|
211
224
|
}
|
|
212
225
|
function lintNodeByKind(// skipcq: JS-R1005
|
|
213
|
-
node, path, add, options) {
|
|
226
|
+
node, path, add, scope, options) {
|
|
214
227
|
switch (node.kind) {
|
|
215
228
|
case "trigger":
|
|
216
229
|
return;
|
|
217
230
|
case "tool":
|
|
218
|
-
lintToolNode(node, path, add, options);
|
|
231
|
+
lintToolNode(node, path, add, scope, options);
|
|
219
232
|
return;
|
|
220
233
|
case "filter":
|
|
221
|
-
validateCondition(node.when, `${path}.when`, add);
|
|
234
|
+
validateCondition(node.when, `${path}.when`, add, scope);
|
|
222
235
|
return;
|
|
223
236
|
case "switch":
|
|
224
|
-
lintSwitchNode(node, path, add);
|
|
237
|
+
lintSwitchNode(node, path, add, scope);
|
|
225
238
|
return;
|
|
226
239
|
case "loop":
|
|
227
|
-
lintLoopNode(node, path, add);
|
|
240
|
+
lintLoopNode(node, path, add, scope);
|
|
228
241
|
return;
|
|
229
242
|
case "merge":
|
|
230
243
|
if (typeof node.strategy !== "string" || !MERGE_STRATEGIES.has(node.strategy)) {
|
|
@@ -232,13 +245,13 @@ node, path, add, options) {
|
|
|
232
245
|
}
|
|
233
246
|
return;
|
|
234
247
|
case "set":
|
|
235
|
-
lintSetNode(node, path, add);
|
|
248
|
+
lintSetNode(node, path, add, scope);
|
|
236
249
|
return;
|
|
237
250
|
case "wait":
|
|
238
|
-
lintWaitNode(node, path, add);
|
|
251
|
+
lintWaitNode(node, path, add, scope);
|
|
239
252
|
return;
|
|
240
253
|
case "approval":
|
|
241
|
-
lintApprovalNode(node, path, add);
|
|
254
|
+
lintApprovalNode(node, path, add, scope);
|
|
242
255
|
return;
|
|
243
256
|
case "code":
|
|
244
257
|
// v2 `code` nodes run through the same sandbox as v1 transform steps, so
|
|
@@ -254,7 +267,7 @@ node, path, add, options) {
|
|
|
254
267
|
if (!isNonEmptyString(node.workflow_id)) {
|
|
255
268
|
add(`${path}.workflow_id`, "invalid_workflow_ref", "Workflow node workflow_id is required.");
|
|
256
269
|
}
|
|
257
|
-
validateValueRefRecord(node.input, `${path}.input`, add);
|
|
270
|
+
validateValueRefRecord(node.input, `${path}.input`, add, scope);
|
|
258
271
|
// Authoring-time refusal, not just a runtime one. A sub-workflow needs its
|
|
259
272
|
// own durable run, lease, spend ceiling and cancellation semantics, none of
|
|
260
273
|
// which exist yet — so the worker rejects the node terminally. Without this
|
|
@@ -267,7 +280,7 @@ node, path, add, options) {
|
|
|
267
280
|
return;
|
|
268
281
|
}
|
|
269
282
|
}
|
|
270
|
-
function lintToolNode(node, path, add, options) {
|
|
283
|
+
function lintToolNode(node, path, add, scope, options) {
|
|
271
284
|
if (!isNonEmptyString(node.tool)) {
|
|
272
285
|
add(`${path}.tool`, "missing_tool", "Tool node tool id is required.");
|
|
273
286
|
}
|
|
@@ -339,9 +352,9 @@ function lintToolNode(node, path, add, options) {
|
|
|
339
352
|
add(`${path}.connection_id`, "invalid_connection_id", "Tool node connection_id must be a non-empty string.");
|
|
340
353
|
}
|
|
341
354
|
validateOptionalMaxCredits(node.max_credits, `${path}.max_credits`, add);
|
|
342
|
-
validateValueRefRecord(node.params, `${path}.params`, add);
|
|
355
|
+
validateValueRefRecord(node.params, `${path}.params`, add, scope);
|
|
343
356
|
}
|
|
344
|
-
function lintSwitchNode(node, path, add) {
|
|
357
|
+
function lintSwitchNode(node, path, add, scope) {
|
|
345
358
|
const cases = asArray(node.cases);
|
|
346
359
|
if (!cases || cases.length === 0) {
|
|
347
360
|
add(`${path}.cases`, "invalid_switch_case", "Switch node requires at least one case.");
|
|
@@ -374,11 +387,11 @@ function lintSwitchNode(node, path, add) {
|
|
|
374
387
|
if (!isNonEmptyString(entry.label)) {
|
|
375
388
|
add(`${casePath}.label`, "invalid_switch_case", "Switch case label is required.");
|
|
376
389
|
}
|
|
377
|
-
validateCondition(entry.when, `${casePath}.when`, add);
|
|
390
|
+
validateCondition(entry.when, `${casePath}.when`, add, scope);
|
|
378
391
|
});
|
|
379
392
|
}
|
|
380
|
-
function lintLoopNode(node, path, add) {
|
|
381
|
-
validateValueRef(node.over, `${path}.over`, add);
|
|
393
|
+
function lintLoopNode(node, path, add, scope) {
|
|
394
|
+
validateValueRef(node.over, `${path}.over`, add, scope);
|
|
382
395
|
if (node.item_name !== undefined && !isNonEmptyString(node.item_name)) {
|
|
383
396
|
add(`${path}.item_name`, "invalid_value_ref", "Loop item_name must be a non-empty string.");
|
|
384
397
|
}
|
|
@@ -398,7 +411,7 @@ function lintLoopNode(node, path, add) {
|
|
|
398
411
|
add(`${path}.concurrency`, "invalid_loop_target", "Loop concurrency must be a positive integer.");
|
|
399
412
|
}
|
|
400
413
|
}
|
|
401
|
-
function lintSetNode(node, path, add) {
|
|
414
|
+
function lintSetNode(node, path, add, scope) {
|
|
402
415
|
const fields = asArray(node.fields);
|
|
403
416
|
if (!fields || fields.length === 0) {
|
|
404
417
|
add(`${path}.fields`, "invalid_set_field", "Set node requires at least one field.");
|
|
@@ -416,16 +429,22 @@ function lintSetNode(node, path, add) {
|
|
|
416
429
|
else if (RESERVED_NODE_IDS.has(field.key)) {
|
|
417
430
|
add(`${fieldPath}.key`, "invalid_set_field", `Set field key '${field.key}' is reserved and would be dropped from the node output.`);
|
|
418
431
|
}
|
|
419
|
-
validateValueRef(field.value, `${fieldPath}.value`, add);
|
|
432
|
+
validateValueRef(field.value, `${fieldPath}.value`, add, scope);
|
|
420
433
|
});
|
|
421
434
|
}
|
|
422
|
-
function lintApprovalNode(node, path, add) {
|
|
435
|
+
function lintApprovalNode(node, path, add, scope) {
|
|
436
|
+
// A blank `reason` is deliberately NOT refused here. It leaves the approver
|
|
437
|
+
// guessing, but it is legal and every gate saved to date is missing one — so
|
|
438
|
+
// the nudge lives in the editor's warning layer (graphWarnings in
|
|
439
|
+
// apps/web/src/lib/workflows/graph-ops.ts), where it can be said without
|
|
440
|
+
// making an existing workflow unsaveable.
|
|
423
441
|
if (node.reason !== undefined)
|
|
424
|
-
validateValueRef(node.reason, `${path}.reason`, add);
|
|
425
|
-
if (node.request !== undefined)
|
|
426
|
-
validateValueRefRecord(node.request, `${path}.request`, add);
|
|
442
|
+
validateValueRef(node.reason, `${path}.reason`, add, scope);
|
|
443
|
+
if (node.request !== undefined) {
|
|
444
|
+
validateValueRefRecord(node.request, `${path}.request`, add, scope);
|
|
445
|
+
}
|
|
427
446
|
}
|
|
428
|
-
function lintWaitNode(node, path, add) {
|
|
447
|
+
function lintWaitNode(node, path, add, scope) {
|
|
429
448
|
const hasDuration = node.duration_seconds !== undefined;
|
|
430
449
|
const hasUntil = node.until !== undefined;
|
|
431
450
|
if (hasDuration === hasUntil) {
|
|
@@ -440,7 +459,7 @@ function lintWaitNode(node, path, add) {
|
|
|
440
459
|
}
|
|
441
460
|
return;
|
|
442
461
|
}
|
|
443
|
-
validateValueRef(node.until, `${path}.until`, add);
|
|
462
|
+
validateValueRef(node.until, `${path}.until`, add, scope);
|
|
444
463
|
}
|
|
445
464
|
function isPositiveInteger(value) {
|
|
446
465
|
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
@@ -593,7 +612,7 @@ function lintGraphTopology(graph, add) {
|
|
|
593
612
|
// ---------------------------------------------------------------------------
|
|
594
613
|
// Value refs and conditions
|
|
595
614
|
// ---------------------------------------------------------------------------
|
|
596
|
-
function validateValueRefRecord(value, path, add) {
|
|
615
|
+
function validateValueRefRecord(value, path, add, scope) {
|
|
597
616
|
if (value === undefined) {
|
|
598
617
|
add(path, "invalid_value_ref", "Node bindings are required.");
|
|
599
618
|
return;
|
|
@@ -603,10 +622,11 @@ function validateValueRefRecord(value, path, add) {
|
|
|
603
622
|
return;
|
|
604
623
|
}
|
|
605
624
|
for (const [key, entry] of Object.entries(value)) {
|
|
606
|
-
validateValueRef(entry, `${path}.${key}`, add);
|
|
625
|
+
validateValueRef(entry, `${path}.${key}`, add, scope);
|
|
607
626
|
}
|
|
608
627
|
}
|
|
609
|
-
function validateValueRef(
|
|
628
|
+
function validateValueRef(// skipcq: JS-R1005
|
|
629
|
+
value, path, add, scope) {
|
|
610
630
|
if (!isRecord(value)) {
|
|
611
631
|
add(path, "invalid_value_ref", "Value ref must be an object.");
|
|
612
632
|
return;
|
|
@@ -626,21 +646,21 @@ function validateValueRef(value, path, add) {
|
|
|
626
646
|
// Only OUR tokens are checked: `{{column}}`, `{{RANDOM|a|b}}` and other
|
|
627
647
|
// foreign templating share the delimiters, travel inside these same
|
|
628
648
|
// parameters, and are none of the linter's business.
|
|
629
|
-
validateStructuredLiteralPaths(value.value, `${path}.value`, add);
|
|
649
|
+
validateStructuredLiteralPaths(value.value, `${path}.value`, add, scope);
|
|
630
650
|
return;
|
|
631
651
|
case "template":
|
|
632
652
|
if (typeof value.value !== "string") {
|
|
633
653
|
add(`${path}.value`, "invalid_value_ref", "Template value ref requires a string.");
|
|
634
654
|
return;
|
|
635
655
|
}
|
|
636
|
-
validateTemplatePaths(value.value, `${path}.value`, add);
|
|
656
|
+
validateTemplatePaths(value.value, `${path}.value`, add, scope);
|
|
637
657
|
return;
|
|
638
658
|
case "ref":
|
|
639
659
|
if (!isNonEmptyString(value.path)) {
|
|
640
660
|
add(`${path}.path`, "invalid_value_ref", "Ref value ref requires a path.");
|
|
641
661
|
return;
|
|
642
662
|
}
|
|
643
|
-
validateScopePath(value.path, `${path}.path`, add);
|
|
663
|
+
validateScopePath(value.path, `${path}.path`, add, scope);
|
|
644
664
|
return;
|
|
645
665
|
case "formula":
|
|
646
666
|
if (!isNonEmptyString(value.expression)) {
|
|
@@ -673,7 +693,7 @@ function validateValueRef(value, path, add) {
|
|
|
673
693
|
add(`${path}.type`, "invalid_value_ref", "Value ref type is invalid.");
|
|
674
694
|
}
|
|
675
695
|
}
|
|
676
|
-
function validateScopePath(path, issuePath, add) {
|
|
696
|
+
function validateScopePath(path, issuePath, add, scope) {
|
|
677
697
|
const segments = parseScopePath(path);
|
|
678
698
|
if (!segments) {
|
|
679
699
|
add(issuePath, "invalid_value_ref", `Path '${path}' is not a valid scope path (it is malformed or reaches a reserved key).`);
|
|
@@ -682,6 +702,49 @@ function validateScopePath(path, issuePath, add) {
|
|
|
682
702
|
const root = segments[0] ?? "";
|
|
683
703
|
if (!SCOPE_PATH_ROOTS.has(root)) {
|
|
684
704
|
add(issuePath, "invalid_value_ref", `Path '${path}' must start with ${[...SCOPE_PATH_ROOTS].join(", ")}.`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
validateScopeNodeId(path, segments, issuePath, add, scope);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The node a `steps.` / `loop.` path reads from must exist.
|
|
711
|
+
*
|
|
712
|
+
* Checking only the ROOT, as this linter did until this rule landed, let
|
|
713
|
+
* `steps.doesnotexist.output` and `steps.enrich.otuput` both save, publish, and
|
|
714
|
+
* resolve to `undefined` mid-run with nothing reported anywhere — a silent
|
|
715
|
+
* wrong-answer, since a missing binding is indistinguishable from a provider
|
|
716
|
+
* that returned nothing. Authors reach for the free-text path constantly,
|
|
717
|
+
* because most Oxygen tools publish no output schema and the guided picker can
|
|
718
|
+
* only offer "Whole output" until the workflow has run once.
|
|
719
|
+
*
|
|
720
|
+
* DELIBERATELY STOPS AT THE NODE ID. The field after it is not checked and must
|
|
721
|
+
* not be: the manifest carries no output schema for a tool, so
|
|
722
|
+
* `steps.enrich.output.anything` is a legitimate binding whose shape is only
|
|
723
|
+
* known once the tool answers. Flagging it would refuse correct workflows, which
|
|
724
|
+
* is strictly worse than the silence this rule removes. The id is the part that
|
|
725
|
+
* is always knowable, and it is where a typo is unrecoverable.
|
|
726
|
+
*
|
|
727
|
+
* Upstream-ness is deliberately NOT checked either. A ref to a node that has not
|
|
728
|
+
* run resolves to `undefined` just as surely, but "has run" is not a static
|
|
729
|
+
* property of the graph: a body node's outputs survive its loop under a keyed
|
|
730
|
+
* step id, a `fallthrough` switch runs several branches, and an author normally
|
|
731
|
+
* configures a node before wiring it in. Every one of those is a legitimate
|
|
732
|
+
* graph that a reachability rule would refuse.
|
|
733
|
+
*/
|
|
734
|
+
function validateScopeNodeId(path, segments, issuePath, add, scope) {
|
|
735
|
+
const root = segments[0];
|
|
736
|
+
if (root !== "steps" && root !== "loop")
|
|
737
|
+
return; // `trigger` names no node.
|
|
738
|
+
const id = segments[1];
|
|
739
|
+
// A bare `steps` / `loop` is the whole scope object, not a reference to a node.
|
|
740
|
+
if (id === undefined)
|
|
741
|
+
return;
|
|
742
|
+
if (!scope.ids.has(id)) {
|
|
743
|
+
add(issuePath, "unknown_node_ref", `Path '${path}' reads from node '${id}', which this workflow does not have. Check the node id.`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (root === "loop" && !scope.loops.has(id)) {
|
|
747
|
+
add(issuePath, "unknown_node_ref", `Path '${path}' reads a loop item from node '${id}', which is not a loop node.`);
|
|
685
748
|
}
|
|
686
749
|
}
|
|
687
750
|
/** Paths inside a context value have no scope root — only syntax to check. */
|
|
@@ -690,12 +753,12 @@ function validateOptionalRefPath(path, issuePath, add) {
|
|
|
690
753
|
add(issuePath, "invalid_value_ref", "Context path is malformed or reaches a reserved key.");
|
|
691
754
|
}
|
|
692
755
|
}
|
|
693
|
-
function validateTemplatePaths(template, issuePath, add) {
|
|
756
|
+
function validateTemplatePaths(template, issuePath, add, scope) {
|
|
694
757
|
for (const match of template.matchAll(/\{\{([^{}]*)\}\}/g)) {
|
|
695
758
|
const path = (match[1] ?? "").trim();
|
|
696
759
|
// "{{}}" is literal text by design, so it is not a binding to validate.
|
|
697
760
|
if (path)
|
|
698
|
-
validateScopePath(path, issuePath, add);
|
|
761
|
+
validateScopePath(path, issuePath, add, scope);
|
|
699
762
|
}
|
|
700
763
|
}
|
|
701
764
|
/**
|
|
@@ -718,7 +781,7 @@ function validateTemplatePaths(template, issuePath, add) {
|
|
|
718
781
|
* empty string. Guessing which of `{{lop.x}}` and `{{company}}` was meant to be
|
|
719
782
|
* ours is not something a linter can do correctly.
|
|
720
783
|
*/
|
|
721
|
-
function validateStructuredLiteralPaths(value, issuePath, add, depth = 0) {
|
|
784
|
+
function validateStructuredLiteralPaths(value, issuePath, add, scope, depth = 0) {
|
|
722
785
|
if (depth > 12)
|
|
723
786
|
return;
|
|
724
787
|
if (typeof value === "string") {
|
|
@@ -731,24 +794,25 @@ function validateStructuredLiteralPaths(value, issuePath, add, depth = 0) {
|
|
|
731
794
|
continue;
|
|
732
795
|
const root = path.split(/[.[]/, 1)[0];
|
|
733
796
|
if (root !== undefined && SCOPE_PATH_ROOTS.has(root)) {
|
|
734
|
-
validateScopePath(path, issuePath, add);
|
|
797
|
+
validateScopePath(path, issuePath, add, scope);
|
|
735
798
|
}
|
|
736
799
|
}
|
|
737
800
|
return;
|
|
738
801
|
}
|
|
739
802
|
if (Array.isArray(value)) {
|
|
740
|
-
for (const entry of value)
|
|
741
|
-
validateStructuredLiteralPaths(entry, issuePath, add, depth + 1);
|
|
803
|
+
for (const entry of value) {
|
|
804
|
+
validateStructuredLiteralPaths(entry, issuePath, add, scope, depth + 1);
|
|
805
|
+
}
|
|
742
806
|
return;
|
|
743
807
|
}
|
|
744
808
|
if (isRecord(value)) {
|
|
745
809
|
for (const entry of Object.values(value)) {
|
|
746
|
-
validateStructuredLiteralPaths(entry, issuePath, add, depth + 1);
|
|
810
|
+
validateStructuredLiteralPaths(entry, issuePath, add, scope, depth + 1);
|
|
747
811
|
}
|
|
748
812
|
}
|
|
749
813
|
}
|
|
750
814
|
function validateCondition(// skipcq: JS-R1005
|
|
751
|
-
value, path, add, depth = 0) {
|
|
815
|
+
value, path, add, scope, depth = 0) {
|
|
752
816
|
if (!isRecord(value)) {
|
|
753
817
|
add(path, "invalid_condition", "Condition must be an object.");
|
|
754
818
|
return;
|
|
@@ -764,17 +828,33 @@ value, path, add, depth = 0) {
|
|
|
764
828
|
if (value.not !== undefined && typeof value.not !== "boolean") {
|
|
765
829
|
add(`${path}.not`, "invalid_condition", "Condition group not must be a boolean.");
|
|
766
830
|
}
|
|
767
|
-
|
|
831
|
+
// An empty group is refused EXCEPT the one form that is vacuously TRUE:
|
|
832
|
+
// `all` with no children and no `not`, which evaluateCondition returns true
|
|
833
|
+
// for by the Array.every identity (expression.ts). That form is how a
|
|
834
|
+
// switch spells an "otherwise" arm — a final always-matching case which,
|
|
835
|
+
// because switch is first-match-wins, fires only when nothing above did.
|
|
836
|
+
//
|
|
837
|
+
// The other two empty shapes stay refused BECAUSE they are the opposite:
|
|
838
|
+
// empty `any` is vacuously false, and empty `all` inverted by `not` is
|
|
839
|
+
// false too, so either one is an arm that can never fire — the same silent
|
|
840
|
+
// dead end this rule exists to prevent, wearing different clothes.
|
|
841
|
+
if (!Array.isArray(value.children)) {
|
|
768
842
|
add(`${path}.children`, "invalid_condition", "Condition group requires at least one child.");
|
|
769
843
|
return;
|
|
770
844
|
}
|
|
845
|
+
if (value.children.length === 0 && !(value.op === "all" && value.not !== true)) {
|
|
846
|
+
add(`${path}.children`, "invalid_condition", value.op === "any"
|
|
847
|
+
? "An empty 'any' group never matches, so this arm could never run. Add a condition, or use an empty 'all' group as a catch-all."
|
|
848
|
+
: "An inverted empty group never matches, so this arm could never run. Remove the inversion to make it a catch-all.");
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
771
851
|
value.children.forEach((child, index) => {
|
|
772
|
-
validateCondition(child, `${path}.children.${index}`, add, depth + 1);
|
|
852
|
+
validateCondition(child, `${path}.children.${index}`, add, scope, depth + 1);
|
|
773
853
|
});
|
|
774
854
|
return;
|
|
775
855
|
}
|
|
776
856
|
if (value.type === "compare") {
|
|
777
|
-
validateValueRef(value.left, `${path}.left`, add);
|
|
857
|
+
validateValueRef(value.left, `${path}.left`, add, scope);
|
|
778
858
|
if (!isCompareOp(value.op)) {
|
|
779
859
|
add(`${path}.op`, "invalid_condition", "Condition compare op is invalid.");
|
|
780
860
|
return;
|
|
@@ -789,7 +869,7 @@ value, path, add, depth = 0) {
|
|
|
789
869
|
add(`${path}.right`, "invalid_condition", `Operator '${value.op}' requires a right operand.`);
|
|
790
870
|
return;
|
|
791
871
|
}
|
|
792
|
-
validateValueRef(value.right, `${path}.right`, add);
|
|
872
|
+
validateValueRef(value.right, `${path}.right`, add, scope);
|
|
793
873
|
if (value.op === "matches")
|
|
794
874
|
validateMatchPattern(value.right, `${path}.right`, add);
|
|
795
875
|
return;
|