Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ No process runs between events: the handler wakes, executes to its next await, p
and the other provider namespaces remain follow-up work; see
[the generator notes](../packages/surface/src/helpers/README.md).

A namespace is not a promise of a whole vendor API. The generated catalog
records `supported` as `true` (every resource in `resources` dispatches),
`'partial'` (usable, but known to omit workflows the namespace suggests —
`note` says which), or `false` (no upstream writeback client). `f.gitlab` is
`'partial'`: it carries `comments` and `discussions` only, so issue
list/read/create and merge-request list/read/create are unavailable through
it, where `f.github` carries issues, pull-requests, reviews, refs, merge, and
close-pull-request. Reaching for an absent resource does not fail as
`undefined is not a function`. `flows check` refuses statically evident
access with `helper_provider.unsupported`, and a dynamically computed name
reaches the surface's property guard, which throws
`f.gitlab.issues is unavailable; available resources: comments, discussions.`
followed by the catalog note — the same wording from both, and mapped at the
body boundary onto `helper_provider.unsupported`. Neither path performs
provider I/O to refuse.

The initial local memory slice supports `recall` and `why` in authored flows,
with no journal step for either read. Script scope is stable across runs of
the same flow file and name; reads cannot widen it to another flow. The
Expand Down
14 changes: 13 additions & 1 deletion packages/sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,19 @@ export async function executeAuthoredFlow<Input = undefined>(
await bodyPromise;
} catch (error) {
bodyFailed = true;
bodyFailure = error;
// The surface refuses an absent helper resource structurally, naming the
// ones that dispatch. Carrying that out as a bare Error would report it as
// an unexplained body crash, so it is remapped onto the code preflight
// already uses for the same refusal — the message is the surface's, not a
// second wording. Every other failure is rethrown exactly as thrown.
//
// Recognised by `name`, not `instanceof`: an authored flow file resolves
// `@relayflows/surface` from its OWN node_modules, so the class that threw
// need not be the one this module imported — and a pinned surface older
// than the guard does not export the class at all.
bodyFailure = error instanceof Error && error.name === 'UnsupportedHelperMemberError'
? new AuthoredFlowExecutionError('helper_provider.unsupported', error.message)
: error;
}
if (bodyFailed) {
try {
Expand Down
52 changes: 1 addition & 51 deletions packages/sdk/src/flow-requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { humanRecipientProvider } from './human-to.js';
import { helperProviders } from '@relayflows/surface/runtime';
import type { TriggerSource } from '@relayflows/surface';
import { providerDeclaration } from './provider-trigger-contract.js';
import { matchingClose, skipCommentOrString, stringEnd } from './source-scan.js';
import type { FlowSpec } from './spec.js';
import { helperCall } from './yaml-helpers.js';

Expand Down Expand Up @@ -216,57 +217,6 @@ function humanRecipients(root: string, body: string): string[] {
return found;
}

/**
* Skip the comment or string starting at `i`, returning the index just past
* it; `i` itself when nothing skippable starts there; -1 when unterminated.
* Every walker below steps through this, so a `,`, `to:` or bracket inside a
* comment or string is never read as syntax.
*/
function skipCommentOrString(text: string, i: number): number {
const ch = text[i]!;
const next = text[i + 1];
if (ch === '/' && next === '/') { const end = text.indexOf('\n', i); return end === -1 ? text.length : end + 1; }
if (ch === '/' && next === '*') { const end = text.indexOf('*/', i + 2); return end === -1 ? -1 : end + 2; }
if (ch === '"' || ch === "'" || ch === '`') { const end = stringEnd(text, i); return end === -1 ? -1 : end + 1; }
return i;
}

/** Index of the `)`/`}`/`]` closing the bracket at `open`, skipping strings, templates and comments; -1 if unbalanced. */
function matchingClose(text: string, open: number): number {
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' };
const stack: string[] = [pairs[text[open]!]!];
let i = open + 1;
while (i < text.length && stack.length > 0) {
const skipped = skipCommentOrString(text, i);
if (skipped === -1) return -1;
if (skipped !== i) { i = skipped; continue; }
const ch = text[i]!;
if (ch in pairs) stack.push(pairs[ch]!);
else if (ch === ')' || ch === '}' || ch === ']') { if (stack.pop() !== ch) return -1; }
i += 1;
}
return stack.length === 0 ? i - 1 : -1;
}

/** Index of the quote closing the string opening at `start` (template `${…}` skipped); -1 if unterminated. */
function stringEnd(text: string, start: number): number {
const quote = text[start]!;
let i = start + 1;
while (i < text.length) {
const ch = text[i]!;
if (ch === '\\') { i += 2; continue; }
if (ch === quote) return i;
if (quote === '`' && ch === '$' && text[i + 1] === '{') {
const end = matchingClose(text, i + 1);
if (end === -1) return -1;
i = end + 1;
continue;
}
i += 1;
}
return -1;
}

/** The `{ … }` that is the call's second top-level argument, or undefined. */
function secondArgumentObject(args: string): string | undefined {
let depth = 0;
Expand Down
89 changes: 84 additions & 5 deletions packages/sdk/src/helper-preflight.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { helperProviders } from '@relayflows/surface/runtime';
import { codeOnly, rebindsIdentifier } from './source-scan.js';
import type { PreflightResult, PreflightDiagnostic } from './preflight.js';

/** Static discovery never executes the body; dynamic aliases are checked at call time. */
Expand All @@ -10,25 +11,103 @@ export function preflightHelpers(
const body = typeof definition.body === 'function' ? Function.prototype.toString.call(definition.body) : '';
const parameter = body.match(/^(?:async\s+)?(?:function(?:\s+[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>)/);
const root = (parameter?.[1] ?? parameter?.[2])?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** End of the body's own parameter declaration, which is not a rebinding of itself. */
const declared = parameter?.[0].length ?? 0;
const diagnostics: PreflightDiagnostic[] = [];
for (const { provider, namespace, supported } of helperProviders) {
for (const provider of helperProviders) {
const { namespace, supported } = provider;
const used = definition.header?.tools?.[namespace] === true
|| (root !== undefined && new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`).test(body));
if (!used) continue;
const fact = facts.providers?.[provider] ?? (provider === 'slack'
const fact = facts.providers?.[provider.provider] ?? (provider.provider === 'slack'
? { mount: facts.slackMount, mock: facts.slackMock, token: facts.slackToken }
: { mount: false, mock: false, token: undefined });
// A resource the helper does not have is refused before the mount question
// and regardless of mock mode: installing a mount cannot conjure a
// writeback route that no client carries, and a body that would die on
// `undefined is not a function` should say so with the names that do work.
const missing = supported === 'partial'
? unavailableMembers(root, declared, namespace, provider.resources, body) : [];
if (!supported) {
diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported',
message: `f.${namespace} has no upstream relayfile writeback client.` });
} else if (missing.length > 0) {
diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported',
message: unavailableMessage(namespace, missing[0]!, provider.resources,
'note' in provider ? provider.note : undefined) });
} else if (!fact.mock && !fact.mount) {
diagnostics.push({ severity: 'refusal',
kind: provider === 'slack' ? (fact.token?.trim() ? 'helper_slack.mount_required' : 'helper_slack.credential_missing') : 'helper_provider.mount_required',
message: `f.${namespace} requires a relayfile ${provider} mount; direct-token transport is not implemented.` });
} else if (provider === 'notion' && !fact.mock && /\.\s*appendBlock\b/.test(body)) {
kind: provider.provider === 'slack' ? (fact.token?.trim() ? 'helper_slack.mount_required' : 'helper_slack.credential_missing') : 'helper_provider.mount_required',
message: `f.${namespace} requires a relayfile ${provider.provider} mount; direct-token transport is not implemented.` });
} else if (provider.provider === 'notion' && !fact.mock && /\.\s*appendBlock\b/.test(body)) {
diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported',
message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' });
}
}
return { ok: diagnostics.length === 0, gates: [], resolutions: [], diagnostics };
}

/**
* The refusal wording for a helper member the provider does not have.
*
* The surface words the same refusal, in `unsupportedHelperMemberMessage`, for
* the guard that catches a computed name at the call site. It is restated here
* rather than imported because this SDK source is installed against a
* PUBLISHED surface — importing a named export the pinned version has not
* shipped fails the whole module at load, before preflight can run at all. The
* catalog data behind the wording (`resources`, `note`) degrades quietly by
* comparison: an older catalog carries no `'partial'` entry, so this refusal
* simply does not arise. `tests/helper-partial-support.test.ts` pins the two
* wordings to the same string.
*/
function unavailableMessage(
namespace: string, member: string, available: readonly string[], note: string | undefined,
): string {
return `f.${namespace}.${member} is unavailable; available resources: ${[...available].sort().join(', ')}.`
+ (note === undefined ? '' : ` ${note}`);
}

/**
* Members read off `f.<namespace>` in the body that the provider does not
* expose, in source order.
*
* Only what is statically evident counts: a direct `.member` or `['member']`
* on the context parameter this body actually declares, matched against
* `codeOnly`, where comments, data strings and regex literals are blanked but
* a literal in property-key position is not. A computed name is not decided
* here — the surface's own property guard refuses it at call time — and
* neither is a body that binds the parameter's name again, where `f.gitlab`
* need not be the flow context at all.
*/
function unavailableMembers(
root: string | undefined, declared: number, namespace: string, resources: readonly string[], body: string,
): string[] {
if (root === undefined) return [];
const code = codeOnly(body);
if (rebindsIdentifier(code, root, declared)) return [];
const access = new RegExp(
`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}|\\[\\s*['"]${namespace}['"]\\s*\\])`
+ `\\s*(?:\\.\\s*([\\w$]+)|\\[\\s*['"]([\\w$]+)['"]\\s*\\])`, 'gu');
const found: string[] = [];
for (const match of code.matchAll(access)) {
const member = match[1] ?? match[2]!;
if (!resolves(member, resources) && !found.includes(member)) found.push(member);
}
return found;
}

/**
* Whether the guarded helper still resolves `member`.
*
* The surface's guard refuses only what is not `in` the bound object and is
* neither `then` nor `toJSON`, so a partial helper keeps ordinary object
* behavior: `f.gitlab.hasOwnProperty('comments')` returns `true` and
* `f.gitlab.toJSON` reads as `undefined`. Preflight has to admit exactly the
* same members, or `flows check` rejects introspection that runs. None of
* these is dispatchable: `invokeHelper` resolves a verb with `Object.hasOwn`,
* which no inherited name satisfies.
*/
function resolves(member: string, resources: readonly string[]): boolean {
return resources.includes(member) || member === 'then' || member === 'toJSON'
|| Reflect.has(Object.prototype, member);
}
Loading
Loading