Skip to content
Merged
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
42 changes: 39 additions & 3 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
GetRecordQuery,
GetRelatedDataQuery,
GetSingleRelatedDataQuery,
ResolvePolymorphicTypeQuery,
UpdateRecordQuery,
} from '../ports/agent-port';
import type SchemaCache from '../schema-cache';
Expand Down Expand Up @@ -222,9 +223,9 @@ export default class AgentClientAgentPort implements AgentPort {
}
}

private createClient(user: StepUser) {
private mintToken(user: StepUser): string {
// snake_case aliases: Ruby/Python agents splat JWT claims into Caller.new (snake_case kwargs).
const token = jsonwebtoken.sign(
return jsonwebtoken.sign(
{
...user,
first_name: user.firstName,
Expand All @@ -236,14 +237,49 @@ export default class AgentClientAgentPort implements AgentPort {
this.authSecret,
{ expiresIn: '5m' },
);
}

private createClient(user: StepUser) {
return createRemoteAgentClient({
url: this.agentUrl,
token,
token: this.mintToken(user),
actionEndpoints: this.buildActionEndpoints(),
});
}

// The agent-client deserializer drops relationship `type`, so read the raw record-with-projection
// response: `data.relationships.<relation>.data = { type, id }`. No UI-exposed discriminator needed.
async resolvePolymorphicType(
{ collection, id, relation }: ResolvePolymorphicTypeQuery,
user: StepUser,
): Promise<{ type: string; id: string } | null> {
return this.callAgent('resolvePolymorphicType', async () => {
const recordId = id.map(String).join('|');
const params = new URLSearchParams({
[`fields[${collection}]`]: relation,
[`fields[${relation}]`]: 'id',
timezone: 'Europe/Paris', // matches HttpRequester's default
});
const base = this.agentUrl.replace(/\/+$/, '');
const response = await fetch(`${base}/forest/${collection}/${recordId}?${params}`, {
headers: { Authorization: `Bearer ${this.mintToken(user)}`, Accept: 'application/json' },
});

if (!response.ok) {
throw new Error(
`resolvePolymorphicType ${collection}/${recordId}: HTTP ${response.status}`,
);
}

const body = (await response.json()) as {
data?: { relationships?: Record<string, { data?: { type?: string; id?: string } | null }> };
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium adapters/agent-client-agent-port.ts:276

resolvePolymorphicType returns { type, id: "undefined" } when the agent returns a linkage with type but no id (e.g., { type: 'orders' }). String(linkage.id) produces the string "undefined" rather than null, unlike getSingleRelatedData which guards against missing id. Downstream fetches will fail when trying to retrieve record "undefined". Consider adding the same guard: return null unless both type and id are present.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/adapters/agent-client-agent-port.ts around line 276:

`resolvePolymorphicType` returns `{ type, id: "undefined" }` when the agent returns a linkage with `type` but no `id` (e.g., `{ type: 'orders' }`). `String(linkage.id)` produces the string `"undefined"` rather than `null`, unlike `getSingleRelatedData` which guards against missing `id`. Downstream fetches will fail when trying to retrieve record `"undefined"`. Consider adding the same guard: return `null` unless both `type` and `id` are present.

Evidence trail:
packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 274-279 (resolvePolymorphicType: no guard on linkage.id), lines 178-180 (getSingleRelatedData: guards with `if (!linkage || !packedId) return null`). JavaScript spec: String(undefined) === "undefined".

const linkage = body?.data?.relationships?.[relation]?.data;

return linkage?.type ? { type: String(linkage.type), id: String(linkage.id) } : null;
});
}

// Hits GET /forest/ (public, no auth required across all agent versions). A 4xx here means
// the URL points to something that isn't a Forest agent. JWT is validated naturally on first step.
async probe(): Promise<void> {
Expand Down
9 changes: 9 additions & 0 deletions packages/workflow-executor/src/executors/agent-with-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
GetRecordQuery,
GetRelatedDataQuery,
GetSingleRelatedDataQuery,
ResolvePolymorphicTypeQuery,
UpdateRecordQuery,
} from '../ports/agent-port';
import type SchemaResolver from '../schema-resolver';
Expand Down Expand Up @@ -116,6 +117,14 @@ export default class AgentWithLog {
return this.agentPort.getActionFormInfo(query, this.user);
}

// Unaudited passthrough: resolves a polymorphic relation's target type (metadata probe). The
// actual related-record load is audited separately, so this records NO activity-log entry.
resolvePolymorphicType(
query: ResolvePolymorphicTypeQuery,
): Promise<{ type: string; id: string } | null> {
return this.agentPort.resolvePolymorphicType(query, this.user);
}

// ISO with the browser engine: `list relation "<displayName>"`. The query carries the technical
// relation name; resolve its displayName from the source schema, falling back to the technical
// name when the field is absent (resilient to orchestrator schema drift).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ interface RelationTarget extends RelationRef {
}

// A relationship reachable from one available record — the unit the AI chooses among.
// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates filters on it).
// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates resolves it, statically
// or — for polymorphic relations — per record from the discriminator).
interface RelationCandidate {
record: RecordRef;
schema: CollectionSchema;
field: FieldSchema & { relatedCollectionName: string };
}

// Followable = has a static target (relatedCollectionName) or is a polymorphic relation resolvable
// per record (polymorphicTypeField names the discriminator). The concrete target is resolved later.
function isFollowableRelation(field: FieldSchema): boolean {
return field.isRelationship && Boolean(field.relatedCollectionName || field.polymorphicTypeField);
}

export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<LoadRelatedRecordStepDefinition> {
protected async doExecute(): Promise<StepExecutionResult> {
// Branch A -- Re-entry after pending execution found in RunStore
Expand Down Expand Up @@ -101,7 +108,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
}

const schema = await this.getCollectionSchema(execution.selectedRecordRef.collectionName);
const target = this.buildTarget(schema, fieldName, execution.selectedRecordRef);
const target = await this.buildTarget(schema, fieldName, execution.selectedRecordRef);
const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target);

await this.context.runStore.saveStepExecution(this.context.runId, {
Expand Down Expand Up @@ -198,31 +205,63 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
const schema = await this.getCollectionSchema(record.collectionName);

for (const field of schema.fields) {
if (field.isRelationship && field.relatedCollectionName) {
candidates.push({
record,
schema,
field: { ...field, relatedCollectionName: field.relatedCollectionName },
});
if (isFollowableRelation(field)) {
// eslint-disable-next-line no-await-in-loop
const relatedCollectionName = await this.resolveTargetCollection(field, record);

// A polymorphic relation with no linked target on this record can't be followed → skip it.
if (relatedCollectionName) {
candidates.push({ record, schema, field: { ...field, relatedCollectionName } });
}
}
}
}

return candidates;
}

private buildTarget(
// Resolves the concrete target collection of a relation for a given source record, or null when
// it can't be followed. Static relations expose `relatedCollectionName`; multi-target polymorphic
// ones expose only `polymorphicTypeField` + candidate models, so the linkage type is read per
// record and mapped to one of the candidates.
private async resolveTargetCollection(
field: FieldSchema,
record: RecordRef,
): Promise<string | null> {
if (field.relatedCollectionName) return field.relatedCollectionName;
if (!field.polymorphicTypeField) return null;

const linkage = await this.context.agent.resolvePolymorphicType({
collection: record.collectionName,
id: record.recordId,
relation: field.fieldName,
});
const discriminator = linkage?.type;
if (!discriminator) return null;

const models = field.polymorphicReferencedModels ?? [];

return (
models.find(m => m === discriminator) ??
models.find(m => m.toLowerCase() === discriminator.toLowerCase()) ??
null
);
}

private async buildTarget(
schema: CollectionSchema,
relationName: string,
selectedRecordRef: RecordRef,
): RelationTarget {
): Promise<RelationTarget> {
const field = this.findFieldByTechnicalName(schema, relationName);

if (!field) {
throw new RelationNotFoundError(relationName, schema.collectionName);
}

if (!field.relatedCollectionName) {
const relatedCollectionName = await this.resolveTargetCollection(field, selectedRecordRef);

if (!relatedCollectionName) {
throw new StepStateError(
`Step at index ${this.context.stepIndex} could not resolve relatedCollectionName for relation "${relationName}"`,
);
Expand All @@ -233,7 +272,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
displayName: field.displayName,
name: field.fieldName,
relationType: field.relationType,
relatedCollectionName: field.relatedCollectionName,
relatedCollectionName,
};
}

Expand All @@ -248,7 +287,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target);

const availableFields: RelationRef[] = sourceSchema.fields
.filter(f => f.isRelationship)
.filter(isFollowableRelation)
.map(f => ({ name: f.fieldName, displayName: f.displayName }));

await this.context.runStore.saveStepExecution(this.context.runId, {
Expand Down Expand Up @@ -395,18 +434,21 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name);
}

// Re-derive relatedCollectionName from the live schema — frontend never sends it.
// Re-derive the target collection from the live schema — frontend never sends it. Handles both
// static relations and polymorphic ones (resolved per record from the discriminator).
const schema = await this.getCollectionSchema(selectedRecordRef.collectionName);
const field = schema.fields.find(f => f.fieldName === name);
const relatedCollectionName =
field && (await this.resolveTargetCollection(field, selectedRecordRef));

if (!field?.relatedCollectionName) {
if (!relatedCollectionName) {
throw new StepStateError(
`Step at index ${this.context.stepIndex} could not resolve relatedCollectionName for relation "${name}"`,
);
}

const record: RecordRef = {
collectionName: field.relatedCollectionName,
collectionName: relatedCollectionName,
recordId: selectedRecordId,
stepIndex: this.context.stepIndex,
};
Expand Down
8 changes: 8 additions & 0 deletions packages/workflow-executor/src/ports/agent-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type ExecuteActionQuery = { collection: string; action: string; id?: Id[]

export type GetActionFormInfoQuery = { collection: string; action: string; id: Id[] };

export type ResolvePolymorphicTypeQuery = { collection: string; id: Id[]; relation: string };

export interface AgentPort {
getRecord(query: GetRecordQuery, user: StepUser): Promise<RecordData>;
updateRecord(query: UpdateRecordQuery, user: StepUser): Promise<RecordData>;
Expand All @@ -48,6 +50,12 @@ export interface AgentPort {
query: GetSingleRelatedDataQuery,
user: StepUser,
): Promise<RecordData | null>;
// Reads a polymorphic relation's target from the raw JSON:API linkage ({ type, id }), which the
// agent-client deserializer drops. Returns null when the record has no linked target.
resolvePolymorphicType(
query: ResolvePolymorphicTypeQuery,
user: StepUser,
): Promise<{ type: string; id: string } | null>;
executeAction(query: ExecuteActionQuery, user: StepUser): Promise<unknown>;
// Old Ruby agents with hooks.load=false return 404; agent-client falls back to the fields
// passed via ActionEndpointsByCollection (populated from the orchestrator's schema).
Expand Down
3 changes: 3 additions & 0 deletions packages/workflow-executor/src/types/validated/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export const FieldSchemaSchema = z.object({
isRelationship: z.boolean(),
relationType: z.enum(['BelongsTo', 'HasMany', 'HasOne', 'BelongsToMany']).optional(),
relatedCollectionName: z.string().optional(),
// Polymorphic relations: discriminator column + candidate target collections, resolved per record.
polymorphicTypeField: z.string().optional(),
polymorphicReferencedModels: z.array(z.string()).optional(),
type: ColumnTypeSchema.nullable().optional(),
enumValues: z.array(z.string()).min(1).optional(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

const mocks = createMockClient();
({ mockCollection, mockRelation, mockAction } = mocks);
mockedCreateRemoteAgentClient.mockReturnValue(mocks.client as any);

Check warning on line 52 in packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Unexpected any. Specify a different type

const schemaCache = new SchemaCache();
schemaCache.set('users', {
Expand Down Expand Up @@ -823,6 +823,74 @@
});
});

describe('resolvePolymorphicType', () => {
let fetchSpy: jest.SpyInstance;

beforeEach(() => {
fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(jest.fn());
});

afterEach(() => {
fetchSpy.mockRestore();
});

function linkageResponse(data: unknown) {
return new Response(JSON.stringify({ data: { relationships: { commentable: { data } } } }), {
status: 200,
});
}

it('reads the linkage type/id and projects the relation on the by-id route', async () => {
fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '99' }));

const result = await port.resolvePolymorphicType(
{ collection: 'comments', id: [7], relation: 'commentable' },
user,
);

expect(result).toEqual({ type: 'orders', id: '99' });

const [url, options] = fetchSpy.mock.calls[0];
expect(url).toContain('http://localhost:3310/forest/comments/7?');
expect(url).toContain(`${encodeURIComponent('fields[comments]')}=commentable`);
expect(url).toContain(`${encodeURIComponent('fields[commentable]')}=id`);
expect(options.headers.Authorization).toMatch(/^Bearer /);
});

it('joins composite ids with "|" in the by-id route', async () => {
fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '1|2' }));

await port.resolvePolymorphicType(
{ collection: 'comments', id: ['tenant-1', 5], relation: 'commentable' },
user,
);

expect(fetchSpy.mock.calls[0][0]).toContain('/forest/comments/tenant-1|5?');
});

it('returns null when the relation has no linkage', async () => {
fetchSpy.mockResolvedValue(linkageResponse(null));

const result = await port.resolvePolymorphicType(
{ collection: 'comments', id: [7], relation: 'commentable' },
user,
);

expect(result).toBeNull();
});

it('throws AgentPortError when the agent responds non-2xx', async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 500 }));

await expect(
port.resolvePolymorphicType(
{ collection: 'comments', id: [7], relation: 'commentable' },
user,
),
).rejects.toThrow(AgentPortError);
});
});

describe('probe', () => {
let fetchSpy: jest.SpyInstance;

Expand Down
21 changes: 21 additions & 0 deletions packages/workflow-executor/test/executors/agent-with-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function makeDeps(overrides: Partial<AgentWithLogDeps> = {}) {
.mockResolvedValue({ collectionName: 'customers', recordId: [42], values: {} }),
getRelatedData: jest.fn().mockResolvedValue([]),
getSingleRelatedData: jest.fn().mockResolvedValue(null),
resolvePolymorphicType: jest.fn().mockResolvedValue({ type: 'orders', id: '99' }),
executeAction: jest.fn().mockResolvedValue({ ok: true }),
getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: true }),
} as unknown as AgentPort;
Expand Down Expand Up @@ -265,4 +266,24 @@ describe('AgentWithLog', () => {
expect(activityLogPort.createPending).not.toHaveBeenCalled();
});
});

describe('resolvePolymorphicType (unaudited passthrough)', () => {
it('forwards to the agent port with the injected user and emits no activity log', async () => {
const { deps, agentPort, activityLogPort } = makeDeps();
const agent = new AgentWithLog(deps);

const result = await agent.resolvePolymorphicType({
collection: 'comments',
id: [7],
relation: 'commentable',
});

expect(agentPort.resolvePolymorphicType).toHaveBeenCalledWith(
{ collection: 'comments', id: [7], relation: 'commentable' },
expect.objectContaining({ id: 1 }),
);
expect(result).toEqual({ type: 'orders', id: '99' });
expect(activityLogPort.createPending).not.toHaveBeenCalled();
});
});
});
Loading
Loading