From ad10c07c7e4ef4ec063efdcc8d867c9b330e0af4 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 20 Oct 2026 13:02:51 +0200 Subject: [PATCH 01/10] Add Create entity and Update entity flow actions Create entity creates an entity of a type, with values for some of its properties, in the web the flow runs in. Update entity sets or removes some of an entity's properties, only for a user who can edit it. A created entity keeps the same UUID on every attempt of its step, so a Temporal retry can't create a second one. Co-authored-by: Cursor --- .../src/activities/flow-activities.ts | 4 + .../create-entity-action.test.ts | 342 ++++++++++++ .../flow-activities/create-entity-action.ts | 154 ++++++ .../flow-activities/persist-entity-action.ts | 97 ++-- .../shared/check-property-values.ts | 118 ++++ .../flow-activities/shared/save-entity.ts | 62 +++ .../update-entity-action.test.ts | 523 ++++++++++++++++++ .../flow-activities/update-entity-action.ts | 216 ++++++++ .../flow-visualizer/run-flow-modal/types.ts | 2 + .../src/flows/action-definitions.ts | 95 ++++ .../src/flows/evaluate-filter.test.ts | 7 +- .../src/flows/property-values.ts | 177 ++++++ .../hash-isomorphic-utils/src/flows/types.ts | 7 + .../flows/validate-flow-definition.test.ts | 220 +++++++- .../src/generate-uuid.ts | 6 +- 15 files changed, 1962 insertions(+), 68 deletions(-) create mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts create mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts create mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts create mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts create mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts create mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts create mode 100644 libs/@local/hash-isomorphic-utils/src/flows/property-values.ts diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities.ts index fea770b1d7a..db81c08d530 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities.ts @@ -1,6 +1,7 @@ import { aggregateAction } from "./flow-activities/aggregate-action.js"; import { analyzeEntityDataAction } from "./flow-activities/analyze-entity-data-action.js"; import { answerQuestionAction } from "./flow-activities/answer-question-action.js"; +import { createEntityAction } from "./flow-activities/create-entity-action.js"; import { filterListAction } from "./flow-activities/filter-list-action.js"; import { findAction } from "./flow-activities/find-action.js"; import { generateChartConfigAction } from "./flow-activities/generate-chart-config-action.js"; @@ -21,6 +22,7 @@ import { processAutomaticBrowsingSettingsAction } from "./flow-activities/proces import { queryEntitiesAction } from "./flow-activities/query-entities-action.js"; import { researchEntitiesAction } from "./flow-activities/research-entities-action.js"; import { takeFirstAction } from "./flow-activities/take-first-action.js"; +import { updateEntityAction } from "./flow-activities/update-entity-action.js"; import { webSearchAction } from "./flow-activities/web-search-action.js"; import { writeGoogleSheetAction } from "./flow-activities/write-google-sheet-action.js"; @@ -35,6 +37,7 @@ export const createFlowActionActivities: CreateFlowActivities< analyzeEntityDataAction, generateStructuralQueryAction, answerQuestionAction, + createEntityAction, filterListAction, findAction, generateChartConfigAction, @@ -53,6 +56,7 @@ export const createFlowActionActivities: CreateFlowActivities< queryEntitiesAction, researchEntitiesAction, takeFirstAction, + updateEntityAction, webSearchAction, writeGoogleSheetAction( params: Omit[0], "vaultClient">, diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts new file mode 100644 index 00000000000..ac62b48296d --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { StatusCode } from "@local/status"; + +import { createEntityAction } from "./create-entity-action.js"; + +import type { + BaseUrl, + EntityId, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; +import type { + HashEntity, + QueryEntitiesResponse, +} from "@local/hash-graph-sdk/entity"; +import type { EntityValidationReport } from "@local/hash-graph-sdk/validation"; +import type { PropertyValues } from "@local/hash-isomorphic-utils/flows/property-values"; +import type { StepInput } from "@local/hash-isomorphic-utils/flows/types"; + +const webId = "00000000-0000-4000-8000-000000000001" as WebId; + +vi.mock("../shared/graph-api-client.js", () => ({ graphApiClient: {} })); +vi.mock("../shared/get-flow-context.js", () => ({ + getFlowContext: () => + Promise.resolve({ + flowEntityId: "flow", + runId: "run", + stepId: "create", + userAuthentication: { actorId: "user" }, + webId, + }), +})); +vi.mock("./shared/save-entity.js", () => ({ + getSavingActorId: () => Promise.resolve("bot"), + retrySave: (save: () => unknown) => save(), +})); +const { validate, create } = vi.hoisted(() => ({ + validate: vi.fn(), + create: vi.fn(), +})); +vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ + ...(await importOriginal()), + HashEntity: { validate, create }, + queryEntities: vi.fn(), +})); + +const personType = + "https://example.com/types/entity-type/person/v/1" as VersionedUrl; +const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; +const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; + +const run = (params: { + entityTypeId?: string; + properties: PropertyValues; + draft?: boolean; +}) => + createEntityAction({ + inputs: [ + { + inputName: "entityTypeId", + payload: { + kind: "VersionedUrl", + value: (params.entityTypeId ?? personType) as VersionedUrl, + }, + }, + { + inputName: "properties", + payload: { kind: "PropertyValues", value: params.properties }, + }, + ...(params.draft === undefined + ? [] + : [ + { + inputName: "draft", + payload: { kind: "Boolean" as const, value: params.draft }, + }, + ]), + ] satisfies StepInput[], + }); + +/** Answers the graph's creation with an entity with the given ID. */ +const mockCreated = (entityId: EntityId) => + create.mockResolvedValueOnce({ entityId } as unknown as HashEntity); + +describe("createEntityAction", () => { + beforeEach(() => { + validate.mockReset().mockResolvedValue(undefined); + create.mockReset(); + vi.mocked(queryEntities) + .mockReset() + .mockResolvedValue({ + entities: [], + cursor: null, + } as unknown as QueryEntitiesResponse); + }); + + it("creates an entity of the type with the values, in the flow's web, and outputs its ID", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; + mockCreated(entityId); + + expect( + await run({ + properties: { [nameUrl]: "Ada", [emailUrl]: ["ada@example.com"] }, + }), + ).toEqual({ + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: entityId }, + }, + ], + }, + ], + }); + + /* Without a data type ID, the graph infers each value's data type from its property type. */ + const properties = { + value: { + [nameUrl]: { value: "Ada", metadata: { dataTypeId: null } }, + [emailUrl]: { + value: [{ value: "ada@example.com", metadata: { dataTypeId: null } }], + metadata: undefined, + }, + }, + metadata: undefined, + }; + + expect(validate.mock.calls).toMatchObject([ + [ + {}, + { actorId: "user" }, + { + entityTypes: [personType], + properties, + components: { requiredProperties: true }, + }, + ], + ]); + expect(create).toHaveBeenCalledWith( + {}, + { actorId: "bot" }, + expect.objectContaining({ + webId, + entityTypeIds: [personType], + properties, + draft: false, + provenance: { + actorType: "machine", + origin: { type: "flow", id: "flow", stepIds: ["create"] }, + }, + }), + ); + }); + + it("creates a draft, which may leave required properties out", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002~00000000-0000-4000-8000-000000000003` as EntityId; + mockCreated(entityId); + + expect( + await run({ properties: { [nameUrl]: "Ada" }, draft: true }), + ).toMatchObject({ + code: StatusCode.Ok, + contents: [{ outputs: [{ payload: { value: entityId } }] }], + }); + expect(validate.mock.calls[0]?.[2].components).toMatchObject({ + requiredProperties: false, + }); + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ draft: true }), + ); + }); + + it.each([ + { + name: "an entity type ID that isn't a versioned URL", + params: { + entityTypeId: "https://example.com/types/entity-type/person/", + properties: { [nameUrl]: "Ada" }, + }, + message: + '"https://example.com/types/entity-type/person/" is not an entity type ID.', + }, + { + name: "no property values", + params: { properties: {} }, + message: "Property values must give a value for at least one property.", + }, + ])( + "fails for $name, without checking or creating anything", + async ({ params, message }) => { + expect(await run(params)).toEqual({ + code: StatusCode.InvalidArgument, + message, + contents: [], + }); + expect(validate).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }, + ); + + it("fails naming each property whose value doesn't fit the type, without creating the entity", async () => { + validate.mockResolvedValueOnce({ + properties: { + [nameUrl]: { + type: "wrongType", + data: { actual: "array", expected: "value" }, + }, + [emailUrl]: { type: "unexpected" }, + }, + } as EntityValidationReport); + + expect( + await run({ + properties: { [nameUrl]: ["Ada"], [emailUrl]: "ada@example.com" }, + }), + ).toEqual({ + code: StatusCode.InvalidArgument, + message: `Property ${nameUrl} takes a single value, but was given a list. Property ${emailUrl} isn't a property of the entity type.`, + contents: [], + }); + expect(create).not.toHaveBeenCalled(); + }); + + it("fails naming each property whose data type the graph can't infer from the value, including in a list", async () => { + validate.mockResolvedValueOnce({ + properties: { + [nameUrl]: { + type: "value", + dataTypeInference: [ + { + type: "ambiguous", + data: [ + "https://example.com/types/data-type/text/v/1", + "https://example.com/types/data-type/uri/v/1", + ], + }, + ], + }, + [emailUrl]: { + type: "array", + validations: [ + { + type: "arrayValidation", + data: { + items: { + "0": { + type: "value", + dataTypeInference: [ + { + type: "abstract", + data: "https://example.com/types/data-type/contact/v/1", + }, + ], + }, + }, + }, + }, + ], + }, + }, + } as unknown as EntityValidationReport); + + expect( + await run({ + properties: { [nameUrl]: "Ada", [emailUrl]: ["ada@example.com"] }, + }), + ).toEqual({ + code: StatusCode.InvalidArgument, + message: `Property ${nameUrl} allows more than one data type, and a flow can't yet say which one its value has. Property ${emailUrl} only allows an abstract data type, and a flow can't yet say which of its subtypes its value has.`, + contents: [], + }); + expect(create).not.toHaveBeenCalled(); + }); + + it("fails for a type the graph can't check the values against", async () => { + validate.mockRejectedValueOnce(new Error("Entity type not found")); + + const { code, message } = await run({ properties: { [nameUrl]: "Ada" } }); + + expect(code).toBe(StatusCode.InvalidArgument); + expect(message).toContain( + `Could not check the property values against ${personType}`, + ); + expect(create).not.toHaveBeenCalled(); + }); + + it("fails if the graph doesn't save the entity", async () => { + create.mockRejectedValueOnce(new Error("Graph unavailable")); + + const { code, message } = await run({ properties: { [nameUrl]: "Ada" } }); + + expect(code).toBe(StatusCode.Internal); + expect(message).toContain("Could not create entity"); + }); + + it("gives the entity the same UUID on every attempt of the step in a run", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; + mockCreated(entityId); + mockCreated(entityId); + + await run({ properties: { [nameUrl]: "Ada" } }); + await run({ properties: { [nameUrl]: "Ada" } }); + + const [first, second] = create.mock.calls.map( + ([, , params]) => params.entityUuid, + ); + + expect(first).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(second).toBe(first); + }); + + it("outputs the entity an earlier attempt of the step created, without creating a second one", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; + vi.mocked(queryEntities).mockResolvedValueOnce({ + entities: [{ entityId }], + cursor: null, + } as unknown as QueryEntitiesResponse); + + expect(await run({ properties: { [nameUrl]: "Ada" } })).toMatchObject({ + code: StatusCode.Ok, + contents: [{ outputs: [{ payload: { value: entityId } }] }], + }); + + const query = vi.mocked(queryEntities).mock.calls[0]; + expect(query?.[1]).toEqual({ actorId: "bot" }); + expect(query?.[2].includeDrafts).toBe(true); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts new file mode 100644 index 00000000000..fbc263aee9c --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts @@ -0,0 +1,154 @@ +import { + type EntityUuid, + validateVersionedUrl, +} from "@blockprotocol/type-system"; +import { + HashEntity, + mergePropertyObjectAndMetadata, + queryEntities, +} from "@local/hash-graph-sdk/entity"; +import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; +import { validatePropertyValues } from "@local/hash-isomorphic-utils/flows/property-values"; +import { generateUuidFromName } from "@local/hash-isomorphic-utils/generate-uuid"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { StatusCode } from "@local/status"; + +import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; +import { getFlowContext } from "../shared/get-flow-context.js"; +import { graphApiClient } from "../shared/graph-api-client.js"; +import { checkPropertyValues } from "./shared/check-property-values.js"; +import { getSavingActorId, retrySave } from "./shared/save-entity.js"; + +import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; + +const createdEntityUuidNamespace = "3c0f8a52-6d1e-4b7a-9f24-81e5c7d2a6b9"; + +const invalidArgument = (message: string) => ({ + code: StatusCode.InvalidArgument, + message, + contents: [], +}); + +/** + * Creates an entity of a type in the flow's web, with the given property values, as the web's machine on behalf of the + * user running the flow. The values are checked against the type first, so that an error names the property; a draft + * may leave required properties out. + */ +export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ + inputs, +}) => { + const { + entityTypeId, + properties, + draft = false, + } = getSimplifiedFlowActionInputs({ inputs, actionType: "createEntity" }); + + if (validateVersionedUrl(entityTypeId).type !== "Ok") { + return invalidArgument( + `${JSON.stringify(entityTypeId)} is not an entity type ID.`, + ); + } + + const propertiesError = validatePropertyValues(properties); + + if (propertiesError) { + return invalidArgument(propertiesError); + } + + const { flowEntityId, runId, stepId, userAuthentication, webId } = + await getFlowContext(); + + const propertiesWithMetadata = mergePropertyObjectAndMetadata( + properties, + undefined, + ); + + const valuesError = await checkPropertyValues({ + actorId: userAuthentication.actorId, + entityTypeIds: [entityTypeId], + properties: propertiesWithMetadata, + requiredProperties: !draft, + }); + + if (valuesError) { + return invalidArgument(valuesError); + } + + const savingActorId = await getSavingActorId({ + actorId: userAuthentication.actorId, + webId, + isAiGenerated: false, + }); + + /* + * The same for every attempt of the step in this run, including Temporal's retries, so that an attempt after one + * that created the entity finds it rather than creating a second one. + */ + const entityUuid = generateUuidFromName( + `${runId}/${stepId}`, + createdEntityUuidNamespace, + ) as EntityUuid; + + let entity: HashEntity; + + try { + entity = await retrySave(async () => { + const { + entities: [created], + } = await queryEntities( + { graphApi: graphApiClient }, + { actorId: savingActorId }, + { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + return ( + created ?? + HashEntity.create( + graphApiClient, + { actorId: savingActorId }, + { + webId, + entityUuid, + entityTypeIds: [entityTypeId], + properties: propertiesWithMetadata, + draft, + provenance: { + actorType: "machine", + origin: { type: "flow", id: flowEntityId, stepIds: [stepId] }, + }, + }, + ) + ); + }); + } catch (error) { + return { + code: StatusCode.Internal, + message: `Could not create entity: ${extractErrorMessage(error)}`, + contents: [], + }; + } + + return { + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: entity.entityId }, + }, + ], + }, + ], + }; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts index a35c5b6acc2..a2c4e6183aa 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts @@ -1,12 +1,10 @@ import { Context } from "@temporalio/activity"; -import { backOff } from "exponential-backoff"; import { extractEntityUuidFromEntityId } from "@blockprotocol/type-system"; import { getStorageProvider, resolvePayloadValue, } from "@local/hash-backend-utils/flows/payload-storage"; -import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; import { HashEntity, HashLinkEntity, @@ -16,7 +14,6 @@ import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flow import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { StatusCode } from "@local/status"; -import { getAiAssistantAccountIdActivity } from "../get-ai-assistant-account-id-activity.js"; import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; import { createInferredEntityNotification } from "../shared/create-inferred-entity-notification.js"; import { @@ -31,6 +28,7 @@ import { getEntityUpdate, getLatestEntityById, } from "./shared/graph-requests.js"; +import { getSavingActorId, retrySave } from "./shared/save-entity.js"; import type { MatchedEntityUpdate } from "../shared/match-existing-entity.js"; import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; @@ -101,30 +99,11 @@ export const persistEntity = async ({ const isAiGenerated = provenance.actorType === "ai"; - const webBotActorId = isAiGenerated - ? await getAiAssistantAccountIdActivity({ - authentication: { actorId }, - graphApiClient, - grantCreatePermissionForWeb: webId, - }) - : await getWebMachineId( - { graphApi: graphApiClient }, - { actorId }, - { webId }, - ).then((maybeMachineId) => { - if (!maybeMachineId) { - throw new Error( - `Failed to get web bot account ID for web ID: ${webId}`, - ); - } - return maybeMachineId; - }); - - if (!webBotActorId) { - throw new Error( - `Could not get ${isAiGenerated ? "AI" : "web"} bot for web ${webId}`, - ); - } + const webBotActorId = await getSavingActorId({ + actorId, + webId, + isAiGenerated, + }); /** * @todo: determine whether the entity type ID is a file entity type ID @@ -206,47 +185,35 @@ export const persistEntity = async ({ */ const stableReferenceToMatchedEntity = matchedEntityUpdate; - entity = await backOff( - () => - stableReferenceToMatchedEntity.existingEntity.patch( - graphApiClient, - { actorId: webBotActorId }, - { - entityTypeIds: - stableReferenceToMatchedEntity.newValues.entityTypeIds, - draft: existingEntityIsDraft ? true : createEditionAsDraft, - propertyPatches: patchOperations, - provenance: { - ...entityValues.provenance, - sources: - stableReferenceToMatchedEntity.newValues.editionSources, - }, + entity = await retrySave(() => + stableReferenceToMatchedEntity.existingEntity.patch( + graphApiClient, + { actorId: webBotActorId }, + { + entityTypeIds: + stableReferenceToMatchedEntity.newValues.entityTypeIds, + draft: existingEntityIsDraft ? true : createEditionAsDraft, + propertyPatches: patchOperations, + provenance: { + ...entityValues.provenance, + sources: + stableReferenceToMatchedEntity.newValues.editionSources, }, - ), - { - jitter: "full", - numOfAttempts: 3, - startingDelay: 1_000, - }, + }, + ), ); } else { - entity = await backOff( - () => - HashEntity.create( - graphApiClient, - { actorId: webBotActorId }, - { - ...entityValues, - draft: createEditionAsDraft, - entityUuid, - webId, - }, - ), - { - jitter: "full", - numOfAttempts: 3, - startingDelay: 1_000, - }, + entity = await retrySave(() => + HashEntity.create( + graphApiClient, + { actorId: webBotActorId }, + { + ...entityValues, + draft: createEditionAsDraft, + entityUuid, + webId, + }, + ), ); } } catch (err) { diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts new file mode 100644 index 00000000000..0614eee0eaf --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts @@ -0,0 +1,118 @@ +import { typedEntries } from "@local/advanced-types/typed-entries"; +import { HashEntity } from "@local/hash-graph-sdk/entity"; + +import { extractErrorMessage } from "../../infer-entities/shared/extract-validation-failure-details.js"; +import { graphApiClient } from "../../shared/graph-api-client.js"; + +import type { + PropertyObjectWithMetadata, + UserId, + VersionedUrl, +} from "@blockprotocol/type-system"; +import type { + DataTypeInferenceError, + ObjectPropertyValidationReport, +} from "@local/hash-graph-sdk/validation"; + +const singularOrList = { + value: "a single value", + array: "a list", + object: "a property object", +}; + +/** The graph's reasons it couldn't infer a value's data type, anywhere in a property's report, e.g. in a list's items. */ +const findDataTypeInferenceErrors = ( + report: unknown, +): DataTypeInferenceError[] => { + if (typeof report !== "object" || report === null) { + return []; + } + + if (Array.isArray(report)) { + return report.flatMap(findDataTypeInferenceErrors); + } + + return Object.entries(report).flatMap(([key, value]) => + key === "dataTypeInference" + ? (value as DataTypeInferenceError[]) + : findDataTypeInferenceErrors(value), + ); +}; + +/** Why the graph rejected a property's value, as a reason that follows the property's name. */ +const describeReport = (report: ObjectPropertyValidationReport): string => { + switch (report.type) { + case "unexpected": + return "isn't a property of the entity type"; + case "missing": + return "is required"; + case "wrongType": + return `takes ${singularOrList[report.data.expected]}, but was given ${singularOrList[report.data.actual]}`; + } + + /* Values are given without a data type ID, so the graph can only infer one for a property with a single concrete data type. */ + const inferenceErrors = findDataTypeInferenceErrors(report); + + if (inferenceErrors.some(({ type }) => type === "ambiguous")) { + return "allows more than one data type, and a flow can't yet say which one its value has"; + } + + if (inferenceErrors.some(({ type }) => type === "abstract")) { + return "only allows an abstract data type, and a flow can't yet say which of its subtypes its value has"; + } + + return "has a value its property type doesn't allow"; +}; + +/** + * Checks property values against entity types, as the user running the flow, so that the step can name each + * property whose value the graph would reject. Returns why they're invalid, or `undefined` if they're valid. + * + * Required properties are only checked if `requiredProperties` is set: an update gives only the properties it changes, + * and a draft may leave required properties out. + */ +export const checkPropertyValues = async ({ + actorId, + entityTypeIds, + properties, + requiredProperties, +}: { + actorId: UserId; + entityTypeIds: [VersionedUrl, ...VersionedUrl[]]; + properties: PropertyObjectWithMetadata; + requiredProperties: boolean; +}): Promise => { + let report; + + try { + report = await HashEntity.validate( + graphApiClient, + { actorId }, + { + entityTypes: entityTypeIds, + properties, + components: { + linkData: false, + linkValidation: false, + numItems: true, + requiredProperties, + }, + }, + ); + } catch (error) { + return `Could not check the property values against ${entityTypeIds.join(", ")}: ${extractErrorMessage(error)}`; + } + + if (!report) { + return undefined; + } + + const propertyErrors = typedEntries(report.properties ?? {}).map( + ([baseUrl, propertyReport]) => + `Property ${baseUrl} ${describeReport(propertyReport)}.`, + ); + + return propertyErrors.length > 0 + ? propertyErrors.join(" ") + : `The property values don't fit ${entityTypeIds.join(", ")}: ${JSON.stringify(report)}`; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts new file mode 100644 index 00000000000..46367359c18 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts @@ -0,0 +1,62 @@ +import { backOff } from "exponential-backoff"; + +import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; + +import { getAiAssistantAccountIdActivity } from "../../get-ai-assistant-account-id-activity.js"; +import { graphApiClient } from "../../shared/graph-api-client.js"; + +import type { + AiId, + MachineId, + UserId, + WebId, +} from "@blockprotocol/type-system"; + +/** + * The bot a flow saves entities in a web as, on behalf of the user running it: the AI assistant for entities an AI + * proposed, otherwise the web's machine. + */ +export const getSavingActorId = async ({ + actorId, + webId, + isAiGenerated, +}: { + actorId: UserId; + webId: WebId; + isAiGenerated: boolean; +}): Promise => { + const botActorId = isAiGenerated + ? await getAiAssistantAccountIdActivity({ + authentication: { actorId }, + graphApiClient, + grantCreatePermissionForWeb: webId, + }) + : await getWebMachineId( + { graphApi: graphApiClient }, + { actorId }, + { webId }, + ).then((maybeMachineId) => { + if (!maybeMachineId) { + throw new Error( + `Failed to get web bot account ID for web ID: ${webId}`, + ); + } + return maybeMachineId; + }); + + if (!botActorId) { + throw new Error( + `Could not get ${isAiGenerated ? "AI" : "web"} bot for web ${webId}`, + ); + } + + return botActorId; +}; + +/** Saves an entity, retrying a failed attempt twice. */ +export const retrySave = (save: () => Promise): Promise => + backOff(save, { + jitter: "full", + numOfAttempts: 3, + startingDelay: 1_000, + }); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts new file mode 100644 index 00000000000..a09f45b4510 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts @@ -0,0 +1,523 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { storePayload } from "@local/hash-backend-utils/flows/payload-storage"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { StatusCode } from "@local/status"; + +import { updateEntityAction } from "./update-entity-action.js"; + +import type { + BaseUrl, + EntityId, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; +import type { FileStorageProvider } from "@local/hash-backend-utils/file-storage"; +import type { + QueryEntitiesResponse, + HashEntity, +} from "@local/hash-graph-sdk/entity"; +import type { EntityValidationReport } from "@local/hash-graph-sdk/validation"; +import type { PropertyValues } from "@local/hash-isomorphic-utils/flows/property-values"; +import type { + PayloadValue, + StepInput, +} from "@local/hash-isomorphic-utils/flows/types"; + +const workflowId = "workflow"; +const webId = "00000000-0000-4000-8000-000000000001" as WebId; +const objects = new Map(); +const storageProvider = { + uploadDirect: ({ key, body }: { key: string; body: string }) => { + objects.set(key, body); + return Promise.resolve(); + }, + downloadDirect: ({ key }: { key: string }) => + Promise.resolve(Buffer.from(objects.get(key) ?? "null")), +} as unknown as FileStorageProvider; + +vi.mock("../shared/graph-api-client.js", () => ({ graphApiClient: {} })); +vi.mock("../shared/get-flow-context.js", () => ({ + getFlowContext: () => + Promise.resolve({ + flowEntityId: "flow", + stepId: "update", + userAuthentication: { actorId: "user" }, + webId, + workflowId, + }), +})); +vi.mock( + "@local/hash-backend-utils/flows/payload-storage", + async (importOriginal) => ({ + ...(await importOriginal()), + getStorageProvider: () => storageProvider, + }), +); +vi.mock("./shared/save-entity.js", () => ({ + getSavingActorId: () => Promise.resolve("bot"), + retrySave: (save: () => unknown) => save(), +})); +const { validate } = vi.hoisted(() => ({ + validate: vi.fn(), +})); +vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ + ...(await importOriginal()), + HashEntity: { validate }, + queryEntities: vi.fn(), +})); + +const personType = + "https://example.com/types/entity-type/person/v/1" as VersionedUrl; +const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; +const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; +const textType = "https://example.com/types/data-type/text/v/1"; + +const entityIdOf = (index: number, draft = false) => + `${webId}~00000000-0000-4000-8000-${String(index).padStart(12, "0")}${ + draft ? "~00000000-0000-4000-8000-0000000000ff" : "" + }` as EntityId; + +const patch = vi.fn(); + +/** Answers the graph query with the entity, if one is given, with the given properties, and whether the user can edit it. */ +const mockEntity = ( + entity: { entityId: EntityId; properties: Record } | null, + { canEdit = true } = {}, +) => + vi.mocked(queryEntities).mockResolvedValueOnce({ + permissions: + entity && canEdit ? { [entity.entityId]: { update: ["edition"] } } : {}, + entities: entity + ? [ + { + ...entity, + propertiesWithMetadata: { + value: Object.fromEntries( + Object.entries(entity.properties).map(([baseUrl, value]) => [ + baseUrl, + { value, metadata: { dataTypeId: textType } }, + ]), + ), + }, + propertiesMetadata: { value: {} }, + metadata: { + recordId: { entityId: entity.entityId }, + entityTypeIds: [personType], + provenance: { edition: { sources: [] } }, + }, + patch, + }, + ] + : [], + cursor: null, + } as unknown as QueryEntitiesResponse); + +const run = (params: { + entityId: PayloadValue<"EntityId", false>; + properties?: PropertyValues; + removeProperties?: BaseUrl[]; +}) => + updateEntityAction({ + inputs: [ + { + inputName: "entityId", + payload: { kind: "EntityId", value: params.entityId }, + }, + ...(params.properties === undefined + ? [] + : [ + { + inputName: "properties", + payload: { + kind: "PropertyValues" as const, + value: params.properties, + }, + }, + ]), + ...(params.removeProperties === undefined + ? [] + : [ + { + inputName: "removeProperties", + payload: { + kind: "BaseUrl" as const, + value: params.removeProperties, + }, + }, + ]), + ] satisfies StepInput[], + }); + +const updated = (entityId: EntityId) => ({ + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: entityId }, + }, + ], + }, + ], +}); + +describe("updateEntityAction", () => { + beforeEach(() => { + validate.mockReset().mockResolvedValue(undefined); + vi.mocked(queryEntities).mockReset(); + patch.mockReset().mockResolvedValue({}); + }); + + it("replaces the whole list of a list property, adds a property the entity lacks, and outputs the same ID", async () => { + mockEntity({ + entityId: entityIdOf(1), + properties: { [emailUrl]: ["ada@example.com", "ada@example.org"] }, + }); + + expect( + await run({ + entityId: entityIdOf(1), + properties: { [emailUrl]: ["lovelace@example.com"], [nameUrl]: "Ada" }, + }), + ).toEqual(updated(entityIdOf(1))); + + expect(validate.mock.calls).toMatchObject([ + [ + {}, + { actorId: "user" }, + { + entityTypes: [personType], + components: { requiredProperties: false }, + }, + ], + ]); + expect(patch).toHaveBeenCalledWith( + {}, + { actorId: "bot" }, + { + propertyPatches: [ + { + op: "replace", + path: [emailUrl], + property: { + value: [ + { + value: "lovelace@example.com", + metadata: { dataTypeId: null }, + }, + ], + }, + }, + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: { dataTypeId: null } }, + }, + ], + draft: false, + provenance: { + actorType: "machine", + origin: { type: "flow", id: "flow", stepIds: ["update"] }, + }, + }, + ); + }); + + it("updates the draft an entity ID with a draft ID names, which stays a draft", async () => { + mockEntity({ entityId: entityIdOf(1, true), properties: {} }); + + expect( + await run({ + entityId: entityIdOf(1, true), + properties: { [nameUrl]: "Ada" }, + }), + ).toEqual(updated(entityIdOf(1, true))); + + const query = vi.mocked(queryEntities).mock.calls[0]?.[2]; + expect(query?.includeDrafts).toBe(true); + expect(query?.includePermissions).toBe(true); + expect(query?.filter).toMatchObject({ + all: expect.arrayContaining([ + { + equal: [ + { path: ["draftId"] }, + { parameter: "00000000-0000-4000-8000-0000000000ff" }, + ], + }, + ]) as unknown[], + }); + expect(patch).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ draft: true }), + ); + }); + + it("updates the entity an item of a stored list of entity IDs names", async () => { + const stored = await storePayload({ + storageProvider, + workflowId, + runId: "run", + stepId: "query", + outputName: "entities", + kind: "EntityId", + value: [entityIdOf(1), entityIdOf(2)], + }); + + mockEntity({ entityId: entityIdOf(2), properties: {} }); + + expect( + await run({ + entityId: { + __stored: true, + kind: "EntityId", + array: false, + of: stored, + index: 1, + }, + properties: { [nameUrl]: "Grace" }, + }), + ).toEqual(updated(entityIdOf(2))); + }); + + it.each([ + { + name: "an entity ID that isn't valid", + entityId: "" as EntityId, + properties: { [nameUrl]: "Ada" }, + code: StatusCode.InvalidArgument, + message: " is not a valid EntityId.", + }, + { + name: "an entity in another web", + entityId: + "00000000-0000-4000-8000-0000000000aa~00000000-0000-4000-8000-000000000001" as EntityId, + properties: { [nameUrl]: "Ada" }, + code: StatusCode.InvalidArgument, + message: + "Entity 00000000-0000-4000-8000-0000000000aa~00000000-0000-4000-8000-000000000001 isn't in the web the flow runs in, so it can't be updated.", + }, + { + name: "no property values", + entityId: entityIdOf(1), + properties: {}, + code: StatusCode.InvalidArgument, + message: "Property values must give a value for at least one property.", + }, + { + name: "nothing to set or remove", + entityId: entityIdOf(1), + removeProperties: [], + code: StatusCode.InvalidArgument, + message: "Give property values to set, properties to remove, or both.", + }, + { + name: "a property to remove named by a versioned URL", + entityId: entityIdOf(1), + removeProperties: [ + "https://example.com/types/property-type/name/v/1" as BaseUrl, + ], + code: StatusCode.InvalidArgument, + message: + '"https://example.com/types/property-type/name/v/1" is not a property type base URL.', + }, + { + name: "a property both set and removed", + entityId: entityIdOf(1), + properties: { [nameUrl]: "Ada" }, + removeProperties: [nameUrl], + code: StatusCode.InvalidArgument, + message: `Property ${nameUrl} can't be both set and removed.`, + }, + ])( + "fails for $name, without reading or updating the entity", + async ({ entityId, properties, removeProperties, code, message }) => { + expect(await run({ entityId, properties, removeProperties })).toEqual({ + code, + message, + contents: [], + }); + expect(queryEntities).not.toHaveBeenCalled(); + expect(patch).not.toHaveBeenCalled(); + }, + ); + + it("fails for an entity that is missing or the user can't read", async () => { + mockEntity(null); + + expect( + await run({ entityId: entityIdOf(1), properties: { [nameUrl]: "Ada" } }), + ).toEqual({ + code: StatusCode.NotFound, + message: `Entity ${entityIdOf(1)} doesn't exist or can't be read by the user running the flow.`, + contents: [], + }); + }); + + it("fails for an entity the user can read but not edit, without updating it", async () => { + mockEntity({ entityId: entityIdOf(1), properties: {} }, { canEdit: false }); + + expect( + await run({ entityId: entityIdOf(1), properties: { [nameUrl]: "Ada" } }), + ).toEqual({ + code: StatusCode.PermissionDenied, + message: `The user running the flow can't edit entity ${entityIdOf(1)}.`, + contents: [], + }); + expect(validate).not.toHaveBeenCalled(); + expect(patch).not.toHaveBeenCalled(); + }); + + it("fails naming the property whose value doesn't fit the entity's types, without updating it", async () => { + mockEntity({ entityId: entityIdOf(1), properties: {} }); + validate.mockResolvedValueOnce({ + properties: { + [emailUrl]: { type: "value", validations: [] }, + }, + } as unknown as EntityValidationReport); + + expect( + await run({ entityId: entityIdOf(1), properties: { [emailUrl]: 42 } }), + ).toEqual({ + code: StatusCode.InvalidArgument, + message: `Property ${emailUrl} has a value its property type doesn't allow.`, + contents: [], + }); + expect(patch).not.toHaveBeenCalled(); + }); + + it("fails if the graph doesn't save the update", async () => { + mockEntity({ entityId: entityIdOf(1), properties: {} }); + patch.mockRejectedValueOnce(new Error("Graph unavailable")); + + const { code, message } = await run({ + entityId: entityIdOf(1), + properties: { [nameUrl]: "Ada" }, + }); + + expect(code).toBe(StatusCode.Internal); + expect(message).toContain(`Could not update entity ${entityIdOf(1)}`); + }); + + it("removes a property from a live entity, checking the entity it leaves against its types", async () => { + mockEntity({ + entityId: entityIdOf(1), + properties: { [nameUrl]: "Ada", [emailUrl]: "ada@example.com" }, + }); + + expect( + await run({ + entityId: entityIdOf(1), + properties: { [nameUrl]: "Ada Lovelace" }, + removeProperties: [emailUrl], + }), + ).toEqual(updated(entityIdOf(1))); + + expect(validate.mock.calls).toMatchObject([ + [ + {}, + { actorId: "user" }, + { + entityTypes: [personType], + properties: { + value: { + [nameUrl]: { + value: "Ada Lovelace", + metadata: { dataTypeId: null }, + }, + }, + }, + components: { requiredProperties: true }, + }, + ], + ]); + expect( + Object.keys(validate.mock.calls[0]?.[2].properties.value ?? {}), + ).toEqual([nameUrl]); + expect(patch).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + propertyPatches: [ + { + op: "replace", + path: [nameUrl], + property: { + value: "Ada Lovelace", + metadata: { dataTypeId: null }, + }, + }, + { op: "remove", path: [emailUrl] }, + ], + }), + ); + }); + + it("keeps the metadata of the values it leaves when checking the entity", async () => { + mockEntity({ + entityId: entityIdOf(1), + properties: { [nameUrl]: "Ada", [emailUrl]: "ada@example.com" }, + }); + + await run({ entityId: entityIdOf(1), removeProperties: [emailUrl] }); + + expect(validate.mock.calls[0]?.[2].properties).toEqual({ + value: { + [nameUrl]: { value: "Ada", metadata: { dataTypeId: textType } }, + }, + }); + }); + + it("fails naming a required property it would remove from a live entity, without updating it", async () => { + mockEntity({ + entityId: entityIdOf(1), + properties: { [nameUrl]: "Ada" }, + }); + validate.mockResolvedValueOnce({ + properties: { [nameUrl]: { type: "missing" } }, + } as unknown as EntityValidationReport); + + expect( + await run({ entityId: entityIdOf(1), removeProperties: [nameUrl] }), + ).toEqual({ + code: StatusCode.InvalidArgument, + message: `Property ${nameUrl} is required.`, + contents: [], + }); + expect(patch).not.toHaveBeenCalled(); + }); + + it("removes a property from a draft without checking required properties", async () => { + mockEntity({ + entityId: entityIdOf(1, true), + properties: { [nameUrl]: "Ada" }, + }); + + expect( + await run({ entityId: entityIdOf(1, true), removeProperties: [nameUrl] }), + ).toEqual(updated(entityIdOf(1, true))); + + expect(validate).not.toHaveBeenCalled(); + expect(patch).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + propertyPatches: [{ op: "remove", path: [nameUrl] }], + draft: true, + }), + ); + }); + + it("skips a property to remove that the entity doesn't have, e.g. one an earlier attempt removed", async () => { + mockEntity({ entityId: entityIdOf(1), properties: { [nameUrl]: "Ada" } }); + + expect( + await run({ entityId: entityIdOf(1), removeProperties: [emailUrl] }), + ).toEqual(updated(entityIdOf(1))); + + expect(validate).not.toHaveBeenCalled(); + expect(patch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts new file mode 100644 index 00000000000..8051bfddc87 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts @@ -0,0 +1,216 @@ +import { isEntityId, splitEntityId } from "@blockprotocol/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; +import { + getStorageProvider, + resolvePayloadValue, +} from "@local/hash-backend-utils/flows/payload-storage"; +import { + mergePropertyObjectAndMetadata, + queryEntities, +} from "@local/hash-graph-sdk/entity"; +import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; +import { validatePropertyChanges } from "@local/hash-isomorphic-utils/flows/property-values"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { StatusCode } from "@local/status"; + +import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; +import { getFlowContext } from "../shared/get-flow-context.js"; +import { graphApiClient } from "../shared/graph-api-client.js"; +import { checkPropertyValues } from "./shared/check-property-values.js"; +import { getEntityUpdate } from "./shared/graph-requests.js"; +import { getSavingActorId, retrySave } from "./shared/save-entity.js"; + +import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; + +const invalidArgument = (message: string) => ({ + code: StatusCode.InvalidArgument, + message, + contents: [], +}); + +/** + * Sets and removes the given properties of an entity the user running the flow can edit, leaving its other properties + * as they are. A value replaces a list property's whole list. An entity ID with a draft ID updates that draft, which + * stays a draft. + */ +export const updateEntityAction: FlowActionActivity<"updateEntity"> = async ({ + inputs, +}) => { + const { entityId, properties, removeProperties } = + getSimplifiedFlowActionInputs({ + inputs, + actionType: "updateEntity", + }); + + const changesError = validatePropertyChanges({ + properties, + removeProperties, + }); + + if (changesError) { + return invalidArgument(changesError); + } + + const { flowEntityId, stepId, userAuthentication, webId, workflowId } = + await getFlowContext(); + + /* An item of a stored list of entity IDs, e.g. in a for-each step, is a reference to it. */ + const resolvedEntityId = await resolvePayloadValue( + { storageProvider: getStorageProvider(), workflowId }, + "EntityId", + entityId, + ); + + if (!isEntityId(resolvedEntityId)) { + return invalidArgument( + `${String(resolvedEntityId)} is not a valid EntityId.`, + ); + } + + const [entityWebId, entityUuid, draftId] = splitEntityId(resolvedEntityId); + + if (entityWebId !== webId) { + return invalidArgument( + `Entity ${resolvedEntityId} isn't in the web the flow runs in, so it can't be updated.`, + ); + } + + const { + entities: [existingEntity], + permissions, + } = await queryEntities({ graphApi: graphApiClient }, userAuthentication, { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + { equal: [{ path: ["webId"] }, { parameter: entityWebId }] }, + draftId + ? { equal: [{ path: ["draftId"] }, { parameter: draftId }] } + : { not: { exists: { path: ["draftId"] } } }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: !!draftId, + includePermissions: true, + }); + + if (!existingEntity) { + return { + code: StatusCode.NotFound, + message: `Entity ${resolvedEntityId} doesn't exist or can't be read by the user running the flow.`, + contents: [], + }; + } + + /* The web's machine saves the update, so the user's own permission to edit the entity is checked here. */ + if (!permissions?.[existingEntity.entityId]?.update) { + return { + code: StatusCode.PermissionDenied, + message: `The user running the flow can't edit entity ${resolvedEntityId}.`, + contents: [], + }; + } + + const propertiesWithMetadata = mergePropertyObjectAndMetadata( + properties ?? {}, + undefined, + ); + + /* A property the entity doesn't have is skipped, e.g. one an earlier attempt of the step removed. */ + const removedBaseUrls = new Set( + removeProperties?.filter((baseUrl) => + Object.hasOwn(existingEntity.properties, baseUrl), + ), + ); + + const succeeded = () => ({ + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId" as const, + payload: { kind: "EntityId" as const, value: resolvedEntityId }, + }, + ], + }, + ], + }); + + if (properties === undefined && removedBaseUrls.size === 0) { + return succeeded(); + } + + /* + * Removing a property from a live entity may leave out a required one, which only the whole entity shows. Its other + * values keep their metadata, so that their data types aren't inferred again. + */ + const checksWholeEntity = removedBaseUrls.size > 0 && !draftId; + + const valuesError = + checksWholeEntity || properties !== undefined + ? await checkPropertyValues({ + actorId: userAuthentication.actorId, + entityTypeIds: existingEntity.metadata.entityTypeIds, + properties: checksWholeEntity + ? { + value: { + ...Object.fromEntries( + typedEntries( + existingEntity.propertiesWithMetadata.value, + ).filter(([baseUrl]) => !removedBaseUrls.has(baseUrl)), + ), + ...propertiesWithMetadata.value, + }, + } + : propertiesWithMetadata, + requiredProperties: checksWholeEntity, + }) + : undefined; + + if (valuesError) { + return invalidArgument(valuesError); + } + + const savingActorId = await getSavingActorId({ + actorId: userAuthentication.actorId, + webId, + isAiGenerated: false, + }); + + const { patchOperations } = getEntityUpdate({ + existingEntity, + newPropertiesWithMetadata: propertiesWithMetadata, + }); + + try { + await retrySave(() => + existingEntity.patch( + graphApiClient, + { actorId: savingActorId }, + { + propertyPatches: [ + ...patchOperations, + ...[...removedBaseUrls].map((baseUrl) => ({ + op: "remove" as const, + path: [baseUrl], + })), + ], + draft: !!draftId, + provenance: { + actorType: "machine", + origin: { type: "flow", id: flowEntityId, stepIds: [stepId] }, + }, + }, + ), + ); + } catch (error) { + return { + code: StatusCode.Internal, + message: `Could not update entity ${resolvedEntityId}: ${extractErrorMessage(error)}`, + contents: [], + }; + } + + return succeeded(); +}; diff --git a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts index 8ecdc409400..9d200d49790 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts +++ b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts @@ -12,6 +12,7 @@ const unsupportedPayloadKinds = [ /** @todo support ActorType to vary Sheet output formatting */ "ActorType", "Aggregation", + "BaseUrl", "DateTime", "Duration", /** @todo support EntityId – requires updating the EntitySelector */ @@ -20,6 +21,7 @@ const unsupportedPayloadKinds = [ "FailedEntityProposal", "FilterCondition", "PersistedEntityMetadata", + "PropertyValues", "ProposedEntity", "ProposedEntityWithResolvedLinks", "SortKey", diff --git a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts index 19b5e30fb56..5880e274671 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts @@ -1,6 +1,10 @@ import { checkAggregationInput } from "./aggregation.js"; import { checkConditionInput, filterableKinds } from "./filter-condition.js"; import { maxEntitiesPerStep } from "./limits.js"; +import { + checkPropertyValuesInput, + checkUpdateEntityInputs, +} from "./property-values.js"; import { checkSortKeyInput } from "./sort-key.js"; import { checkTakeCountInput } from "./take-count.js"; import { allPayloadKinds } from "./types.js"; @@ -25,6 +29,7 @@ export type AiFlowActionDefinitionId = | "aggregate" | "analyzeEntityData" | "answerQuestion" + | "createEntity" | "filterList" | "find" | "generateChartConfig" @@ -44,6 +49,7 @@ export type AiFlowActionDefinitionId = | "queryEntities" | "researchEntities" | "takeFirst" + | "updateEntity" | "webSearch" | "writeGoogleSheet"; @@ -1031,6 +1037,95 @@ const aiFlowActionDefinitionsAsConst = { }, ], }, + createEntity: { + actionDefinitionId: "createEntity", + name: "Create Entity", + description: + "Create an entity of a type in the web the flow runs in, with values for some of its properties.", + kind: "action", + inputs: [ + { + oneOfPayloadKinds: ["VersionedUrl"], + name: "entityTypeId", + description: "The type of the entity to create.", + required: true, + array: false, + }, + { + oneOfPayloadKinds: ["PropertyValues"], + name: "properties", + description: + "The entity's property values, keyed by property type base URL. A list property takes the whole list.", + required: true, + array: false, + }, + { + oneOfPayloadKinds: ["Boolean"], + name: "draft", + description: + "Whether to create the entity as a draft, whose required properties can be left out.", + required: false, + default: { + kind: "Boolean", + value: false, + }, + array: false, + }, + ], + outputs: [ + { + payloadKind: "EntityId", + name: "entityId", + description: "The new entity, with a draft ID if it's a draft.", + array: false, + required: true, + }, + ], + check: checkPropertyValuesInput, + }, + updateEntity: { + actionDefinitionId: "updateEntity", + name: "Update Entity", + description: + "Set or remove some of the properties of an entity in the web the flow runs in, leaving its other properties as they are.", + kind: "action", + inputs: [ + { + oneOfPayloadKinds: ["EntityId"], + name: "entityId", + description: + "The entity to update: the draft for an entity ID with a draft ID, otherwise the live entity.", + required: true, + array: false, + }, + { + oneOfPayloadKinds: ["PropertyValues"], + name: "properties", + description: + "The new values of the properties to set, keyed by property type base URL. A list property's value replaces the whole list. Optional if properties are removed.", + required: false, + array: false, + }, + { + oneOfPayloadKinds: ["BaseUrl"], + name: "removeProperties", + description: + "The base URLs of the properties to remove. A property the entity doesn't have is skipped. Optional if properties are set.", + required: false, + array: true, + }, + ], + outputs: [ + { + payloadKind: "EntityId", + name: "entityId", + description: "The entity updated: the same entity ID as given.", + array: false, + required: true, + }, + ], + check: checkUpdateEntityInputs, + }, takeFirst: { actionDefinitionId: "takeFirst", name: "Take First", diff --git a/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts index 44a4a49f261..1d27930aa03 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { evaluateFilter } from "./evaluate-filter.js"; import type { FilterValue, ScalarFilterCondition } from "./evaluate-filter.js"; -import type { EntityId } from "@blockprotocol/type-system"; +import type { BaseUrl, EntityId } from "@blockprotocol/type-system"; const entityId = "00000000-0000-4000-8000-000000000001~00000000-0000-4000-8000-000000000002" as EntityId; @@ -23,6 +23,10 @@ const values: FilterValue[] = [ kind: "VersionedUrl", value: "https://example.com/types/entity-type/company/v/1", }, + { + kind: "BaseUrl", + value: "https://example.com/types/property-type/name/" as BaseUrl, + }, ]; describe("evaluateFilter", () => { @@ -227,6 +231,7 @@ describe("evaluateFilter", () => { ActorType: [...notText], GoogleAccountId: [...notText], VersionedUrl: [...notText], + BaseUrl: [...notText], }; it.each(unsupportedOperators[value.kind])("rejects %s", (operator) => { diff --git a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts new file mode 100644 index 00000000000..1722c0cbff1 --- /dev/null +++ b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts @@ -0,0 +1,177 @@ +import { validateBaseUrl } from "@blockprotocol/type-system"; + +import type { ActionStepCheck } from "./types.js"; +import type { BaseUrl, PropertyObject } from "@blockprotocol/type-system"; + +/** + * Values for some of an entity's properties, keyed by property type base URL, e.g. to create an entity with or to + * update one with. A list property's value is the whole list. + * + * Only the shape is checked when the flow is defined, because flows are validated without access to the ontology. The + * values are checked against the entity type when the step runs. + */ +export type PropertyValues = PropertyObject; + +const isPropertyBaseUrl = (value: unknown) => + typeof value === "string" && validateBaseUrl(value).type === "Ok"; + +/** Checks the shape of property values, returning why it's invalid. */ +export const validatePropertyValues = ( + values: PropertyValues, +): string | undefined => { + /* Persisted definitions and flow inputs may be malformed. */ + if ( + typeof values !== "object" || + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + values === null || + Array.isArray(values) + ) { + return "Property values must be an object keyed by property type base URL."; + } + + const keys = Object.keys(values); + + if (keys.length === 0) { + return "Property values must give a value for at least one property."; + } + + const invalidKey = keys.find((key) => !isPropertyBaseUrl(key)); + + if (invalidKey !== undefined) { + return `${JSON.stringify(invalidKey)} is not a property type base URL.`; + } + + return undefined; +}; + +/** Checks a list of properties to remove from an entity, returning why it's invalid. */ +export const validatePropertyRemovals = ( + baseUrls: BaseUrl[], +): string | undefined => { + /* Persisted definitions and flow inputs may be malformed. */ + if (!Array.isArray(baseUrls)) { + return "Properties to remove must be a list of property type base URLs."; + } + + const invalidBaseUrl = baseUrls.find( + (baseUrl) => !isPropertyBaseUrl(baseUrl), + ); + + if (invalidBaseUrl !== undefined) { + return `${JSON.stringify(invalidBaseUrl)} is not a property type base URL.`; + } + + return undefined; +}; + +const noChanges = "Give property values to set, properties to remove, or both."; + +const setAndRemoved = (baseUrl: BaseUrl) => + `Property ${baseUrl} can't be both set and removed.`; + +/** + * Checks the changes an update makes to an entity: values to set, properties to remove, or both. Returns why they're + * invalid. + */ +export const validatePropertyChanges = ({ + properties, + removeProperties, +}: { + properties?: PropertyValues; + removeProperties?: BaseUrl[]; +}): string | undefined => { + if (properties === undefined && !removeProperties?.length) { + return noChanges; + } + + const error = + (properties === undefined + ? undefined + : validatePropertyValues(properties)) ?? + (removeProperties === undefined + ? undefined + : validatePropertyRemovals(removeProperties)); + + if (error) { + return error; + } + + const bothSetAndRemoved = + properties && + removeProperties?.find((baseUrl) => Object.hasOwn(properties, baseUrl)); + + return bothSetAndRemoved ? setAndRemoved(bothSetAndRemoved) : undefined; +}; + +/** Checks a step's constant `properties` input when the flow is defined. */ +export const checkPropertyValuesInput: ActionStepCheck = ({ step, report }) => { + const { properties } = step.inputs; + + /* A constant of another kind is reported as an incompatible connection. */ + if ( + properties?.kind !== "constant" || + properties.payload.kind !== "PropertyValues" + ) { + return; + } + + const error = validatePropertyValues( + properties.payload.value as PropertyValues, + ); + + if (error) { + report("properties", error); + } +}; + +/** + * Checks an Update entity step's inputs when the flow is defined: that it sets or removes something, and that its + * constant `properties` and `removeProperties` are valid and don't name the same property. + */ +export const checkUpdateEntityInputs: ActionStepCheck = (params) => { + const { step, report } = params; + const { properties, removeProperties } = step.inputs; + + if (!properties && !removeProperties) { + report("properties", noChanges); + return; + } + + checkPropertyValuesInput(params); + + /* A constant of another kind is reported as an incompatible connection. */ + if ( + removeProperties?.kind !== "constant" || + removeProperties.payload.kind !== "BaseUrl" + ) { + return; + } + + const baseUrls = removeProperties.payload.value as BaseUrl[]; + + const removalsError = validatePropertyRemovals(baseUrls); + + if (removalsError) { + report("removeProperties", removalsError); + return; + } + + if (!properties && baseUrls.length === 0) { + report("removeProperties", noChanges); + return; + } + + const values = + properties?.kind === "constant" && + properties.payload.kind === "PropertyValues" && + !validatePropertyValues(properties.payload.value as PropertyValues) + ? (properties.payload.value as PropertyValues) + : undefined; + + const bothSetAndRemoved = + values && baseUrls.find((baseUrl) => Object.hasOwn(values, baseUrl)); + + if (bothSetAndRemoved) { + report("removeProperties", setAndRemoved(bothSetAndRemoved)); + } +}; diff --git a/libs/@local/hash-isomorphic-utils/src/flows/types.ts b/libs/@local/hash-isomorphic-utils/src/flows/types.ts index ae428233275..331dcf2b4a3 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/types.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/types.ts @@ -8,9 +8,11 @@ import type { } from "./action-definitions.js"; import type { Aggregation } from "./aggregation.js"; import type { FilterCondition } from "./filter-condition.js"; +import type { PropertyValues } from "./property-values.js"; import type { SortKey } from "./sort-key.js"; import type { ActorEntityUuid, + BaseUrl, EntityId, EntityUuid, PropertyObject, @@ -119,6 +121,8 @@ export type EntityQuery = Pick & export type PayloadKindValues = { ActorType: ActorTypeDataType; Aggregation: Aggregation; + /** A type's URL without its version, e.g. "https://example.com/types/property-type/name/" */ + BaseUrl: BaseUrl; Boolean: boolean; Date: string; // e.g. "2025-01-01" /** An ISO 8601 instant with `Z` or an offset, e.g. "2026-10-16T14:00:00Z" */ @@ -133,6 +137,7 @@ export type PayloadKindValues = { GoogleSheet: GoogleSheet; Number: number; PersistedEntityMetadata: PersistedEntityMetadata; + PropertyValues: PropertyValues; ProposedEntity: ProposedEntity; ProposedEntityWithResolvedLinks: ProposedEntityWithResolvedLinks; SortKey: SortKey; @@ -173,6 +178,7 @@ export type PayloadKindTraits = export const payloadKindTraits = { ActorType: { primitive: "text" }, Aggregation: { primitive: null }, + BaseUrl: { primitive: "text" }, Boolean: { primitive: "boolean" }, Date: { primitive: "date" }, DateTime: { primitive: "datetime" }, @@ -186,6 +192,7 @@ export const payloadKindTraits = { GoogleSheet: { primitive: null, fields: true }, Number: { primitive: "number" }, PersistedEntityMetadata: { primitive: null, fields: true }, + PropertyValues: { primitive: null }, ProposedEntity: { primitive: null, fields: true, entity: true }, ProposedEntityWithResolvedLinks: { primitive: null, diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts index caab3500171..785934043f9 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts @@ -766,7 +766,7 @@ describe("validateFlowDefinition", () => { name: "of a kind that can't be sorted", subject: { kind: "field", path: ["url"], payloadKind: "WebPage" }, message: - "A sort key's subject must be of kind ActorType, Boolean, Date, DateTime, EntityId, GoogleAccountId, Number, Text, VersionedUrl.", + "A sort key's subject must be of kind ActorType, BaseUrl, Boolean, Date, DateTime, EntityId, GoogleAccountId, Number, Text, VersionedUrl.", }, { name: "that doesn't fit the items", @@ -794,6 +794,224 @@ describe("validateFlowDefinition", () => { }); }); + describe.each([ + { + actionDefinitionId: "createEntity", + inputs: { + entityTypeId: { + kind: "constant", + payload: { + kind: "VersionedUrl", + value: "https://example.com/types/entity-type/person/v/1", + }, + }, + }, + }, + { + actionDefinitionId: "updateEntity", + inputs: { entityId: { kind: "flow-input", inputName: "person" } }, + }, + ])( + "properties of $actionDefinitionId steps", + ({ actionDefinitionId, inputs }) => { + const errorsOf = (properties: unknown) => + validateFlowDefinition({ + ...baseDefinition, + inputs: [ + { + name: "person", + payloadKind: "EntityId", + array: false, + required: true, + }, + { + name: "values", + payloadKind: "PropertyValues", + array: false, + required: true, + }, + ], + steps: [ + { + kind: "action", + stepId: "save", + actionDefinitionId, + description: "", + inputs: { ...inputs, properties }, + }, + ], + }).diagnostics.filter(({ severity }) => severity === "error"); + + const constant = (value: unknown) => ({ + kind: "constant", + payload: { kind: "PropertyValues", value }, + }); + + it.each([ + { + name: "a constant", + properties: constant({ + "https://example.com/types/property-type/name/": "Ada", + "https://example.com/types/property-type/email/": [ + "ada@example.com", + ], + }), + }, + { + name: "a flow input, which is checked when the step runs", + properties: { kind: "flow-input", inputName: "values" }, + }, + ])("accepts $name", ({ properties }) => { + expect(errorsOf(properties)).toEqual([]); + }); + + it.each([ + { + name: "no values", + value: {}, + message: + "Property values must give a value for at least one property.", + }, + { + name: "a property type versioned URL rather than a base URL", + value: { "https://example.com/types/property-type/name/v/1": "Ada" }, + message: + '"https://example.com/types/property-type/name/v/1" is not a property type base URL.', + }, + ])("rejects $name", ({ value, message }) => { + expect(errorsOf(constant(value))).toEqual([ + { + severity: "error", + code: "invalidInput", + message, + path: ["steps", 0, "inputs", "properties"], + }, + ]); + }); + }, + ); + + describe("properties to remove of updateEntity steps", () => { + const nameUrl = "https://example.com/types/property-type/name/"; + + const errorsOf = (inputs: Record) => + validateFlowDefinition({ + ...baseDefinition, + inputs: [ + { + name: "person", + payloadKind: "EntityId", + array: false, + required: true, + }, + { + name: "values", + payloadKind: "PropertyValues", + array: false, + required: true, + }, + { + name: "removals", + payloadKind: "BaseUrl", + array: true, + required: true, + }, + ], + steps: [ + { + kind: "action", + stepId: "save", + actionDefinitionId: "updateEntity", + description: "", + inputs: { + entityId: { kind: "flow-input", inputName: "person" }, + ...inputs, + }, + }, + ], + }).diagnostics.filter(({ severity }) => severity === "error"); + + const removals = (value: unknown) => ({ + kind: "constant", + payload: { kind: "BaseUrl", value }, + }); + + const values = (value: unknown) => ({ + kind: "constant", + payload: { kind: "PropertyValues", value }, + }); + + it.each([ + { + name: "only properties to remove", + inputs: { removeProperties: removals([nameUrl]) }, + }, + { + name: "properties to remove from a flow input, which are checked when the step runs", + inputs: { + removeProperties: { kind: "flow-input", inputName: "removals" }, + }, + }, + { + name: "values to set and different properties to remove", + inputs: { + properties: values({ [nameUrl]: "Ada" }), + removeProperties: removals([ + "https://example.com/types/property-type/email/", + ]), + }, + }, + { + name: "no properties to remove alongside values to set", + inputs: { + properties: { kind: "flow-input", inputName: "values" }, + removeProperties: removals([]), + }, + }, + ])("accepts $name", ({ inputs }) => { + expect(errorsOf(inputs)).toEqual([]); + }); + + it.each([ + { + name: "neither values to set nor properties to remove", + inputs: {}, + input: "properties", + message: "Give property values to set, properties to remove, or both.", + }, + { + name: "an empty list of properties to remove alone", + inputs: { removeProperties: removals([]) }, + input: "removeProperties", + message: "Give property values to set, properties to remove, or both.", + }, + { + name: "a property to remove named by a versioned URL", + inputs: { removeProperties: removals([`${nameUrl}v/1`]) }, + input: "removeProperties", + message: `"${nameUrl}v/1" is not a property type base URL.`, + }, + { + name: "a property both set and removed", + inputs: { + properties: values({ [nameUrl]: "Ada" }), + removeProperties: removals([nameUrl]), + }, + input: "removeProperties", + message: `Property ${nameUrl} can't be both set and removed.`, + }, + ])("rejects $name", ({ inputs, input, message }) => { + expect(errorsOf(inputs)).toEqual([ + { + severity: "error", + code: "invalidInput", + message, + path: ["steps", 0, "inputs", input], + }, + ]); + }); + }); + describe("aggregations of aggregate steps", () => { const aggregateDefinition = (aggregation: Record) => ({ ...baseDefinition, diff --git a/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts b/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts index 29c60045acd..ed1ec859c68 100644 --- a/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts +++ b/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts @@ -1,3 +1,7 @@ -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, v5 as uuidv5 } from "uuid"; export const generateUuid = () => uuidv4(); + +/** The same UUID for the same name in the same namespace, which must itself be a UUID. */ +export const generateUuidFromName = (name: string, namespace: string) => + uuidv5(name, namespace); From aac69d145d8e766c8f62594b751a006c5d0fef90 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 20 Oct 2026 13:29:24 +0200 Subject: [PATCH 02/10] Create and update entities with the graph's properties and patches Update entity takes the graph's property patches and applies them as given, and Create entity takes properties with their metadata, so each value names its data type rather than the graph inferring it. The graph validates both, so the separate check against the entity's types is removed. Patches change whole properties, since a patch inside a list isn't safe to retry. Co-authored-by: Cursor --- .../create-entity-action.test.ts | 192 +++------ .../flow-activities/create-entity-action.ts | 34 +- .../shared/check-property-values.ts | 118 ------ .../update-entity-action.test.ts | 368 ++++-------------- .../flow-activities/update-entity-action.ts | 122 ++---- .../flow-visualizer/run-flow-modal/types.ts | 4 +- .../src/flows/action-definitions.ts | 28 +- .../src/flows/evaluate-filter.test.ts | 7 +- .../src/flows/property-values.ts | 229 ++++++----- .../hash-isomorphic-utils/src/flows/types.ts | 15 +- .../flows/validate-flow-definition.test.ts | 313 +++++++-------- 11 files changed, 455 insertions(+), 975 deletions(-) delete mode 100644 apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts index ac62b48296d..a8ff320a925 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -8,6 +8,7 @@ import { createEntityAction } from "./create-entity-action.js"; import type { BaseUrl, EntityId, + PropertyObjectWithMetadata, VersionedUrl, WebId, } from "@blockprotocol/type-system"; @@ -15,8 +16,6 @@ import type { HashEntity, QueryEntitiesResponse, } from "@local/hash-graph-sdk/entity"; -import type { EntityValidationReport } from "@local/hash-graph-sdk/validation"; -import type { PropertyValues } from "@local/hash-isomorphic-utils/flows/property-values"; import type { StepInput } from "@local/hash-isomorphic-utils/flows/types"; const webId = "00000000-0000-4000-8000-000000000001" as WebId; @@ -36,13 +35,12 @@ vi.mock("./shared/save-entity.js", () => ({ getSavingActorId: () => Promise.resolve("bot"), retrySave: (save: () => unknown) => save(), })); -const { validate, create } = vi.hoisted(() => ({ - validate: vi.fn(), +const { create } = vi.hoisted(() => ({ create: vi.fn(), })); vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ ...(await importOriginal()), - HashEntity: { validate, create }, + HashEntity: { create }, queryEntities: vi.fn(), })); @@ -50,10 +48,17 @@ const personType = "https://example.com/types/entity-type/person/v/1" as VersionedUrl; const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; +const text = { + dataTypeId: "https://example.com/types/data-type/text/v/1" as VersionedUrl, +}; + +const ada = { + value: { [nameUrl]: { value: "Ada", metadata: text } }, +} satisfies PropertyObjectWithMetadata; const run = (params: { entityTypeId?: string; - properties: PropertyValues; + properties: PropertyObjectWithMetadata; draft?: boolean; }) => createEntityAction({ @@ -67,7 +72,10 @@ const run = (params: { }, { inputName: "properties", - payload: { kind: "PropertyValues", value: params.properties }, + payload: { + kind: "PropertyObjectWithMetadata", + value: params.properties, + }, }, ...(params.draft === undefined ? [] @@ -86,7 +94,6 @@ const mockCreated = (entityId: EntityId) => describe("createEntityAction", () => { beforeEach(() => { - validate.mockReset().mockResolvedValue(undefined); create.mockReset(); vi.mocked(queryEntities) .mockReset() @@ -96,16 +103,19 @@ describe("createEntityAction", () => { } as unknown as QueryEntitiesResponse); }); - it("creates an entity of the type with the values, in the flow's web, and outputs its ID", async () => { + it("creates an entity of the type with the properties as given, in the flow's web, and outputs its ID", async () => { const entityId = `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; mockCreated(entityId); - expect( - await run({ - properties: { [nameUrl]: "Ada", [emailUrl]: ["ada@example.com"] }, - }), - ).toEqual({ + const properties = { + value: { + [nameUrl]: { value: "Ada", metadata: text }, + [emailUrl]: { value: [{ value: "ada@example.com", metadata: text }] }, + }, + } satisfies PropertyObjectWithMetadata; + + expect(await run({ properties })).toEqual({ code: StatusCode.Ok, contents: [ { @@ -119,29 +129,6 @@ describe("createEntityAction", () => { ], }); - /* Without a data type ID, the graph infers each value's data type from its property type. */ - const properties = { - value: { - [nameUrl]: { value: "Ada", metadata: { dataTypeId: null } }, - [emailUrl]: { - value: [{ value: "ada@example.com", metadata: { dataTypeId: null } }], - metadata: undefined, - }, - }, - metadata: undefined, - }; - - expect(validate.mock.calls).toMatchObject([ - [ - {}, - { actorId: "user" }, - { - entityTypes: [personType], - properties, - components: { requiredProperties: true }, - }, - ], - ]); expect(create).toHaveBeenCalledWith( {}, { actorId: "bot" }, @@ -158,20 +145,15 @@ describe("createEntityAction", () => { ); }); - it("creates a draft, which may leave required properties out", async () => { + it("creates a draft", async () => { const entityId = `${webId}~00000000-0000-4000-8000-000000000002~00000000-0000-4000-8000-000000000003` as EntityId; mockCreated(entityId); - expect( - await run({ properties: { [nameUrl]: "Ada" }, draft: true }), - ).toMatchObject({ + expect(await run({ properties: ada, draft: true })).toMatchObject({ code: StatusCode.Ok, contents: [{ outputs: [{ payload: { value: entityId } }] }], }); - expect(validate.mock.calls[0]?.[2].components).toMatchObject({ - requiredProperties: false, - }); expect(create).toHaveBeenCalledWith( expect.anything(), expect.anything(), @@ -184,122 +166,48 @@ describe("createEntityAction", () => { name: "an entity type ID that isn't a versioned URL", params: { entityTypeId: "https://example.com/types/entity-type/person/", - properties: { [nameUrl]: "Ada" }, + properties: ada, }, message: '"https://example.com/types/entity-type/person/" is not an entity type ID.', }, { - name: "no property values", - params: { properties: {} }, - message: "Property values must give a value for at least one property.", + name: "no properties", + params: { properties: { value: {} } }, + message: "Properties must give a value for at least one property.", + }, + { + name: "a value without a data type ID", + params: { + properties: { + value: { + [nameUrl]: { value: "Ada", metadata: { dataTypeId: null } }, + }, + }, + }, + message: `Property ${nameUrl} must give the data type ID of each of its values.`, }, ])( - "fails for $name, without checking or creating anything", + "fails for $name, without creating anything", async ({ params, message }) => { expect(await run(params)).toEqual({ code: StatusCode.InvalidArgument, message, contents: [], }); - expect(validate).not.toHaveBeenCalled(); + expect(queryEntities).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled(); }, ); - it("fails naming each property whose value doesn't fit the type, without creating the entity", async () => { - validate.mockResolvedValueOnce({ - properties: { - [nameUrl]: { - type: "wrongType", - data: { actual: "array", expected: "value" }, - }, - [emailUrl]: { type: "unexpected" }, - }, - } as EntityValidationReport); - - expect( - await run({ - properties: { [nameUrl]: ["Ada"], [emailUrl]: "ada@example.com" }, - }), - ).toEqual({ - code: StatusCode.InvalidArgument, - message: `Property ${nameUrl} takes a single value, but was given a list. Property ${emailUrl} isn't a property of the entity type.`, - contents: [], - }); - expect(create).not.toHaveBeenCalled(); - }); - - it("fails naming each property whose data type the graph can't infer from the value, including in a list", async () => { - validate.mockResolvedValueOnce({ - properties: { - [nameUrl]: { - type: "value", - dataTypeInference: [ - { - type: "ambiguous", - data: [ - "https://example.com/types/data-type/text/v/1", - "https://example.com/types/data-type/uri/v/1", - ], - }, - ], - }, - [emailUrl]: { - type: "array", - validations: [ - { - type: "arrayValidation", - data: { - items: { - "0": { - type: "value", - dataTypeInference: [ - { - type: "abstract", - data: "https://example.com/types/data-type/contact/v/1", - }, - ], - }, - }, - }, - }, - ], - }, - }, - } as unknown as EntityValidationReport); - - expect( - await run({ - properties: { [nameUrl]: "Ada", [emailUrl]: ["ada@example.com"] }, - }), - ).toEqual({ - code: StatusCode.InvalidArgument, - message: `Property ${nameUrl} allows more than one data type, and a flow can't yet say which one its value has. Property ${emailUrl} only allows an abstract data type, and a flow can't yet say which of its subtypes its value has.`, - contents: [], - }); - expect(create).not.toHaveBeenCalled(); - }); - - it("fails for a type the graph can't check the values against", async () => { - validate.mockRejectedValueOnce(new Error("Entity type not found")); - - const { code, message } = await run({ properties: { [nameUrl]: "Ada" } }); - - expect(code).toBe(StatusCode.InvalidArgument); - expect(message).toContain( - `Could not check the property values against ${personType}`, - ); - expect(create).not.toHaveBeenCalled(); - }); - - it("fails if the graph doesn't save the entity", async () => { - create.mockRejectedValueOnce(new Error("Graph unavailable")); + it("fails if the graph doesn't save the entity, e.g. because its properties don't fit the type", async () => { + create.mockRejectedValueOnce(new Error("Entity validation failed")); - const { code, message } = await run({ properties: { [nameUrl]: "Ada" } }); + const { code, message } = await run({ properties: ada }); expect(code).toBe(StatusCode.Internal); expect(message).toContain("Could not create entity"); + expect(message).toContain("Entity validation failed"); }); it("gives the entity the same UUID on every attempt of the step in a run", async () => { @@ -308,8 +216,8 @@ describe("createEntityAction", () => { mockCreated(entityId); mockCreated(entityId); - await run({ properties: { [nameUrl]: "Ada" } }); - await run({ properties: { [nameUrl]: "Ada" } }); + await run({ properties: ada }); + await run({ properties: ada }); const [first, second] = create.mock.calls.map( ([, , params]) => params.entityUuid, @@ -329,7 +237,7 @@ describe("createEntityAction", () => { cursor: null, } as unknown as QueryEntitiesResponse); - expect(await run({ properties: { [nameUrl]: "Ada" } })).toMatchObject({ + expect(await run({ properties: ada })).toMatchObject({ code: StatusCode.Ok, contents: [{ outputs: [{ payload: { value: entityId } }] }], }); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts index fbc263aee9c..211b965df14 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts @@ -2,13 +2,9 @@ import { type EntityUuid, validateVersionedUrl, } from "@blockprotocol/type-system"; -import { - HashEntity, - mergePropertyObjectAndMetadata, - queryEntities, -} from "@local/hash-graph-sdk/entity"; +import { HashEntity, queryEntities } from "@local/hash-graph-sdk/entity"; import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; -import { validatePropertyValues } from "@local/hash-isomorphic-utils/flows/property-values"; +import { validatePropertiesWithMetadata } from "@local/hash-isomorphic-utils/flows/property-values"; import { generateUuidFromName } from "@local/hash-isomorphic-utils/generate-uuid"; import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { StatusCode } from "@local/status"; @@ -16,7 +12,6 @@ import { StatusCode } from "@local/status"; import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; import { getFlowContext } from "../shared/get-flow-context.js"; import { graphApiClient } from "../shared/graph-api-client.js"; -import { checkPropertyValues } from "./shared/check-property-values.js"; import { getSavingActorId, retrySave } from "./shared/save-entity.js"; import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; @@ -30,9 +25,8 @@ const invalidArgument = (message: string) => ({ }); /** - * Creates an entity of a type in the flow's web, with the given property values, as the web's machine on behalf of the - * user running the flow. The values are checked against the type first, so that an error names the property; a draft - * may leave required properties out. + * Creates an entity of a type in the flow's web, with the given properties and their metadata, as the web's machine on + * behalf of the user running the flow. The graph validates the entity; a draft may leave required properties out. */ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ inputs, @@ -49,7 +43,7 @@ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ ); } - const propertiesError = validatePropertyValues(properties); + const propertiesError = validatePropertiesWithMetadata(properties); if (propertiesError) { return invalidArgument(propertiesError); @@ -58,22 +52,6 @@ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ const { flowEntityId, runId, stepId, userAuthentication, webId } = await getFlowContext(); - const propertiesWithMetadata = mergePropertyObjectAndMetadata( - properties, - undefined, - ); - - const valuesError = await checkPropertyValues({ - actorId: userAuthentication.actorId, - entityTypeIds: [entityTypeId], - properties: propertiesWithMetadata, - requiredProperties: !draft, - }); - - if (valuesError) { - return invalidArgument(valuesError); - } - const savingActorId = await getSavingActorId({ actorId: userAuthentication.actorId, webId, @@ -120,7 +98,7 @@ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ webId, entityUuid, entityTypeIds: [entityTypeId], - properties: propertiesWithMetadata, + properties, draft, provenance: { actorType: "machine", diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts deleted file mode 100644 index 0614eee0eaf..00000000000 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/check-property-values.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { typedEntries } from "@local/advanced-types/typed-entries"; -import { HashEntity } from "@local/hash-graph-sdk/entity"; - -import { extractErrorMessage } from "../../infer-entities/shared/extract-validation-failure-details.js"; -import { graphApiClient } from "../../shared/graph-api-client.js"; - -import type { - PropertyObjectWithMetadata, - UserId, - VersionedUrl, -} from "@blockprotocol/type-system"; -import type { - DataTypeInferenceError, - ObjectPropertyValidationReport, -} from "@local/hash-graph-sdk/validation"; - -const singularOrList = { - value: "a single value", - array: "a list", - object: "a property object", -}; - -/** The graph's reasons it couldn't infer a value's data type, anywhere in a property's report, e.g. in a list's items. */ -const findDataTypeInferenceErrors = ( - report: unknown, -): DataTypeInferenceError[] => { - if (typeof report !== "object" || report === null) { - return []; - } - - if (Array.isArray(report)) { - return report.flatMap(findDataTypeInferenceErrors); - } - - return Object.entries(report).flatMap(([key, value]) => - key === "dataTypeInference" - ? (value as DataTypeInferenceError[]) - : findDataTypeInferenceErrors(value), - ); -}; - -/** Why the graph rejected a property's value, as a reason that follows the property's name. */ -const describeReport = (report: ObjectPropertyValidationReport): string => { - switch (report.type) { - case "unexpected": - return "isn't a property of the entity type"; - case "missing": - return "is required"; - case "wrongType": - return `takes ${singularOrList[report.data.expected]}, but was given ${singularOrList[report.data.actual]}`; - } - - /* Values are given without a data type ID, so the graph can only infer one for a property with a single concrete data type. */ - const inferenceErrors = findDataTypeInferenceErrors(report); - - if (inferenceErrors.some(({ type }) => type === "ambiguous")) { - return "allows more than one data type, and a flow can't yet say which one its value has"; - } - - if (inferenceErrors.some(({ type }) => type === "abstract")) { - return "only allows an abstract data type, and a flow can't yet say which of its subtypes its value has"; - } - - return "has a value its property type doesn't allow"; -}; - -/** - * Checks property values against entity types, as the user running the flow, so that the step can name each - * property whose value the graph would reject. Returns why they're invalid, or `undefined` if they're valid. - * - * Required properties are only checked if `requiredProperties` is set: an update gives only the properties it changes, - * and a draft may leave required properties out. - */ -export const checkPropertyValues = async ({ - actorId, - entityTypeIds, - properties, - requiredProperties, -}: { - actorId: UserId; - entityTypeIds: [VersionedUrl, ...VersionedUrl[]]; - properties: PropertyObjectWithMetadata; - requiredProperties: boolean; -}): Promise => { - let report; - - try { - report = await HashEntity.validate( - graphApiClient, - { actorId }, - { - entityTypes: entityTypeIds, - properties, - components: { - linkData: false, - linkValidation: false, - numItems: true, - requiredProperties, - }, - }, - ); - } catch (error) { - return `Could not check the property values against ${entityTypeIds.join(", ")}: ${extractErrorMessage(error)}`; - } - - if (!report) { - return undefined; - } - - const propertyErrors = typedEntries(report.properties ?? {}).map( - ([baseUrl, propertyReport]) => - `Property ${baseUrl} ${describeReport(propertyReport)}.`, - ); - - return propertyErrors.length > 0 - ? propertyErrors.join(" ") - : `The property values don't fit ${entityTypeIds.join(", ")}: ${JSON.stringify(report)}`; -}; diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts index a09f45b4510..36fe3c75120 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts @@ -9,16 +9,12 @@ import { updateEntityAction } from "./update-entity-action.js"; import type { BaseUrl, EntityId, + PropertyPatchOperation, VersionedUrl, WebId, } from "@blockprotocol/type-system"; import type { FileStorageProvider } from "@local/hash-backend-utils/file-storage"; -import type { - QueryEntitiesResponse, - HashEntity, -} from "@local/hash-graph-sdk/entity"; -import type { EntityValidationReport } from "@local/hash-graph-sdk/validation"; -import type { PropertyValues } from "@local/hash-isomorphic-utils/flows/property-values"; +import type { QueryEntitiesResponse } from "@local/hash-graph-sdk/entity"; import type { PayloadValue, StepInput, @@ -58,20 +54,22 @@ vi.mock("./shared/save-entity.js", () => ({ getSavingActorId: () => Promise.resolve("bot"), retrySave: (save: () => unknown) => save(), })); -const { validate } = vi.hoisted(() => ({ - validate: vi.fn(), -})); vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ ...(await importOriginal()), - HashEntity: { validate }, queryEntities: vi.fn(), })); -const personType = - "https://example.com/types/entity-type/person/v/1" as VersionedUrl; const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; -const textType = "https://example.com/types/data-type/text/v/1"; +const text = { + dataTypeId: "https://example.com/types/data-type/text/v/1" as VersionedUrl, +}; + +const setName = { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: text }, +} satisfies PropertyPatchOperation; const entityIdOf = (index: number, draft = false) => `${webId}~00000000-0000-4000-8000-${String(index).padStart(12, "0")}${ @@ -80,43 +78,18 @@ const entityIdOf = (index: number, draft = false) => const patch = vi.fn(); -/** Answers the graph query with the entity, if one is given, with the given properties, and whether the user can edit it. */ -const mockEntity = ( - entity: { entityId: EntityId; properties: Record } | null, - { canEdit = true } = {}, -) => +/** Answers the graph query with an entity with the given ID, if one is given, and whether the user can edit it. */ +const mockEntity = (entityId: EntityId | null, { canEdit = true } = {}) => vi.mocked(queryEntities).mockResolvedValueOnce({ permissions: - entity && canEdit ? { [entity.entityId]: { update: ["edition"] } } : {}, - entities: entity - ? [ - { - ...entity, - propertiesWithMetadata: { - value: Object.fromEntries( - Object.entries(entity.properties).map(([baseUrl, value]) => [ - baseUrl, - { value, metadata: { dataTypeId: textType } }, - ]), - ), - }, - propertiesMetadata: { value: {} }, - metadata: { - recordId: { entityId: entity.entityId }, - entityTypeIds: [personType], - provenance: { edition: { sources: [] } }, - }, - patch, - }, - ] - : [], + entityId && canEdit ? { [entityId]: { update: ["edition"] } } : {}, + entities: entityId ? [{ entityId, patch }] : [], cursor: null, } as unknown as QueryEntitiesResponse); const run = (params: { entityId: PayloadValue<"EntityId", false>; - properties?: PropertyValues; - removeProperties?: BaseUrl[]; + propertyPatches: PayloadValue<"PropertyPatch", true>; }) => updateEntityAction({ inputs: [ @@ -124,28 +97,10 @@ const run = (params: { inputName: "entityId", payload: { kind: "EntityId", value: params.entityId }, }, - ...(params.properties === undefined - ? [] - : [ - { - inputName: "properties", - payload: { - kind: "PropertyValues" as const, - value: params.properties, - }, - }, - ]), - ...(params.removeProperties === undefined - ? [] - : [ - { - inputName: "removeProperties", - payload: { - kind: "BaseUrl" as const, - value: params.removeProperties, - }, - }, - ]), + { + inputName: "propertyPatches", + payload: { kind: "PropertyPatch", value: params.propertyPatches }, + }, ] satisfies StepInput[], }); @@ -165,57 +120,38 @@ const updated = (entityId: EntityId) => ({ describe("updateEntityAction", () => { beforeEach(() => { - validate.mockReset().mockResolvedValue(undefined); vi.mocked(queryEntities).mockReset(); patch.mockReset().mockResolvedValue({}); }); - it("replaces the whole list of a list property, adds a property the entity lacks, and outputs the same ID", async () => { - mockEntity({ - entityId: entityIdOf(1), - properties: { [emailUrl]: ["ada@example.com", "ada@example.org"] }, - }); + it("applies the patches as given, as the web's machine, and outputs the same ID", async () => { + mockEntity(entityIdOf(1)); - expect( - await run({ - entityId: entityIdOf(1), - properties: { [emailUrl]: ["lovelace@example.com"], [nameUrl]: "Ada" }, - }), - ).toEqual(updated(entityIdOf(1))); + const propertyPatches = [ + setName, + { + op: "replace", + path: [emailUrl], + property: { value: [{ value: "ada@example.com", metadata: text }] }, + }, + { + op: "remove", + path: ["https://example.com/types/property-type/age/" as BaseUrl], + }, + ] satisfies PropertyPatchOperation[]; - expect(validate.mock.calls).toMatchObject([ - [ - {}, - { actorId: "user" }, - { - entityTypes: [personType], - components: { requiredProperties: false }, - }, - ], - ]); + expect(await run({ entityId: entityIdOf(1), propertyPatches })).toEqual( + updated(entityIdOf(1)), + ); + + expect(vi.mocked(queryEntities).mock.calls[0]?.[1]).toEqual({ + actorId: "user", + }); expect(patch).toHaveBeenCalledWith( {}, { actorId: "bot" }, { - propertyPatches: [ - { - op: "replace", - path: [emailUrl], - property: { - value: [ - { - value: "lovelace@example.com", - metadata: { dataTypeId: null }, - }, - ], - }, - }, - { - op: "add", - path: [nameUrl], - property: { value: "Ada", metadata: { dataTypeId: null } }, - }, - ], + propertyPatches, draft: false, provenance: { actorType: "machine", @@ -226,13 +162,10 @@ describe("updateEntityAction", () => { }); it("updates the draft an entity ID with a draft ID names, which stays a draft", async () => { - mockEntity({ entityId: entityIdOf(1, true), properties: {} }); + mockEntity(entityIdOf(1, true)); expect( - await run({ - entityId: entityIdOf(1, true), - properties: { [nameUrl]: "Ada" }, - }), + await run({ entityId: entityIdOf(1, true), propertyPatches: [setName] }), ).toEqual(updated(entityIdOf(1, true))); const query = vi.mocked(queryEntities).mock.calls[0]?.[2]; @@ -266,7 +199,7 @@ describe("updateEntityAction", () => { value: [entityIdOf(1), entityIdOf(2)], }); - mockEntity({ entityId: entityIdOf(2), properties: {} }); + mockEntity(entityIdOf(2)); expect( await run({ @@ -277,7 +210,7 @@ describe("updateEntityAction", () => { of: stored, index: 1, }, - properties: { [nameUrl]: "Grace" }, + propertyPatches: [setName], }), ).toEqual(updated(entityIdOf(2))); }); @@ -286,56 +219,51 @@ describe("updateEntityAction", () => { { name: "an entity ID that isn't valid", entityId: "" as EntityId, - properties: { [nameUrl]: "Ada" }, - code: StatusCode.InvalidArgument, + propertyPatches: [setName], message: " is not a valid EntityId.", }, { name: "an entity in another web", entityId: "00000000-0000-4000-8000-0000000000aa~00000000-0000-4000-8000-000000000001" as EntityId, - properties: { [nameUrl]: "Ada" }, - code: StatusCode.InvalidArgument, + propertyPatches: [setName], message: "Entity 00000000-0000-4000-8000-0000000000aa~00000000-0000-4000-8000-000000000001 isn't in the web the flow runs in, so it can't be updated.", }, { - name: "no property values", + name: "no patches", entityId: entityIdOf(1), - properties: {}, - code: StatusCode.InvalidArgument, - message: "Property values must give a value for at least one property.", + propertyPatches: [], + message: "Give at least one property patch.", }, { - name: "nothing to set or remove", + name: "a patch inside a property's value", entityId: entityIdOf(1), - removeProperties: [], - code: StatusCode.InvalidArgument, - message: "Give property values to set, properties to remove, or both.", + propertyPatches: [{ op: "remove", path: [emailUrl, 0] }], + message: `Property patch 1 changes part of property ${emailUrl}, which isn't supported yet: give its whole value.`, }, { - name: "a property to remove named by a versioned URL", + name: "a value without a data type ID", entityId: entityIdOf(1), - removeProperties: [ - "https://example.com/types/property-type/name/v/1" as BaseUrl, + propertyPatches: [ + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: { dataTypeId: null } }, + }, ], - code: StatusCode.InvalidArgument, - message: - '"https://example.com/types/property-type/name/v/1" is not a property type base URL.', - }, - { - name: "a property both set and removed", - entityId: entityIdOf(1), - properties: { [nameUrl]: "Ada" }, - removeProperties: [nameUrl], - code: StatusCode.InvalidArgument, - message: `Property ${nameUrl} can't be both set and removed.`, + message: `Property ${nameUrl} must give the data type ID of each of its values.`, }, - ])( + ] satisfies { + name: string; + entityId: EntityId; + propertyPatches: PropertyPatchOperation[]; + message: string; + }[])( "fails for $name, without reading or updating the entity", - async ({ entityId, properties, removeProperties, code, message }) => { - expect(await run({ entityId, properties, removeProperties })).toEqual({ - code, + async ({ entityId, propertyPatches, message }) => { + expect(await run({ entityId, propertyPatches })).toEqual({ + code: StatusCode.InvalidArgument, message, contents: [], }); @@ -348,7 +276,7 @@ describe("updateEntityAction", () => { mockEntity(null); expect( - await run({ entityId: entityIdOf(1), properties: { [nameUrl]: "Ada" } }), + await run({ entityId: entityIdOf(1), propertyPatches: [setName] }), ).toEqual({ code: StatusCode.NotFound, message: `Entity ${entityIdOf(1)} doesn't exist or can't be read by the user running the flow.`, @@ -357,167 +285,29 @@ describe("updateEntityAction", () => { }); it("fails for an entity the user can read but not edit, without updating it", async () => { - mockEntity({ entityId: entityIdOf(1), properties: {} }, { canEdit: false }); + mockEntity(entityIdOf(1), { canEdit: false }); expect( - await run({ entityId: entityIdOf(1), properties: { [nameUrl]: "Ada" } }), + await run({ entityId: entityIdOf(1), propertyPatches: [setName] }), ).toEqual({ code: StatusCode.PermissionDenied, message: `The user running the flow can't edit entity ${entityIdOf(1)}.`, contents: [], }); - expect(validate).not.toHaveBeenCalled(); expect(patch).not.toHaveBeenCalled(); }); - it("fails naming the property whose value doesn't fit the entity's types, without updating it", async () => { - mockEntity({ entityId: entityIdOf(1), properties: {} }); - validate.mockResolvedValueOnce({ - properties: { - [emailUrl]: { type: "value", validations: [] }, - }, - } as unknown as EntityValidationReport); - - expect( - await run({ entityId: entityIdOf(1), properties: { [emailUrl]: 42 } }), - ).toEqual({ - code: StatusCode.InvalidArgument, - message: `Property ${emailUrl} has a value its property type doesn't allow.`, - contents: [], - }); - expect(patch).not.toHaveBeenCalled(); - }); - - it("fails if the graph doesn't save the update", async () => { - mockEntity({ entityId: entityIdOf(1), properties: {} }); - patch.mockRejectedValueOnce(new Error("Graph unavailable")); + it("fails if the graph doesn't save the update, e.g. because the entity it leaves doesn't fit its types", async () => { + mockEntity(entityIdOf(1)); + patch.mockRejectedValueOnce(new Error("Entity validation failed")); const { code, message } = await run({ entityId: entityIdOf(1), - properties: { [nameUrl]: "Ada" }, + propertyPatches: [setName], }); expect(code).toBe(StatusCode.Internal); expect(message).toContain(`Could not update entity ${entityIdOf(1)}`); - }); - - it("removes a property from a live entity, checking the entity it leaves against its types", async () => { - mockEntity({ - entityId: entityIdOf(1), - properties: { [nameUrl]: "Ada", [emailUrl]: "ada@example.com" }, - }); - - expect( - await run({ - entityId: entityIdOf(1), - properties: { [nameUrl]: "Ada Lovelace" }, - removeProperties: [emailUrl], - }), - ).toEqual(updated(entityIdOf(1))); - - expect(validate.mock.calls).toMatchObject([ - [ - {}, - { actorId: "user" }, - { - entityTypes: [personType], - properties: { - value: { - [nameUrl]: { - value: "Ada Lovelace", - metadata: { dataTypeId: null }, - }, - }, - }, - components: { requiredProperties: true }, - }, - ], - ]); - expect( - Object.keys(validate.mock.calls[0]?.[2].properties.value ?? {}), - ).toEqual([nameUrl]); - expect(patch).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - propertyPatches: [ - { - op: "replace", - path: [nameUrl], - property: { - value: "Ada Lovelace", - metadata: { dataTypeId: null }, - }, - }, - { op: "remove", path: [emailUrl] }, - ], - }), - ); - }); - - it("keeps the metadata of the values it leaves when checking the entity", async () => { - mockEntity({ - entityId: entityIdOf(1), - properties: { [nameUrl]: "Ada", [emailUrl]: "ada@example.com" }, - }); - - await run({ entityId: entityIdOf(1), removeProperties: [emailUrl] }); - - expect(validate.mock.calls[0]?.[2].properties).toEqual({ - value: { - [nameUrl]: { value: "Ada", metadata: { dataTypeId: textType } }, - }, - }); - }); - - it("fails naming a required property it would remove from a live entity, without updating it", async () => { - mockEntity({ - entityId: entityIdOf(1), - properties: { [nameUrl]: "Ada" }, - }); - validate.mockResolvedValueOnce({ - properties: { [nameUrl]: { type: "missing" } }, - } as unknown as EntityValidationReport); - - expect( - await run({ entityId: entityIdOf(1), removeProperties: [nameUrl] }), - ).toEqual({ - code: StatusCode.InvalidArgument, - message: `Property ${nameUrl} is required.`, - contents: [], - }); - expect(patch).not.toHaveBeenCalled(); - }); - - it("removes a property from a draft without checking required properties", async () => { - mockEntity({ - entityId: entityIdOf(1, true), - properties: { [nameUrl]: "Ada" }, - }); - - expect( - await run({ entityId: entityIdOf(1, true), removeProperties: [nameUrl] }), - ).toEqual(updated(entityIdOf(1, true))); - - expect(validate).not.toHaveBeenCalled(); - expect(patch).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - propertyPatches: [{ op: "remove", path: [nameUrl] }], - draft: true, - }), - ); - }); - - it("skips a property to remove that the entity doesn't have, e.g. one an earlier attempt removed", async () => { - mockEntity({ entityId: entityIdOf(1), properties: { [nameUrl]: "Ada" } }); - - expect( - await run({ entityId: entityIdOf(1), removeProperties: [emailUrl] }), - ).toEqual(updated(entityIdOf(1))); - - expect(validate).not.toHaveBeenCalled(); - expect(patch).not.toHaveBeenCalled(); + expect(message).toContain("Entity validation failed"); }); }); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts index 8051bfddc87..b9090a52db9 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts @@ -1,23 +1,17 @@ import { isEntityId, splitEntityId } from "@blockprotocol/type-system"; -import { typedEntries } from "@local/advanced-types/typed-entries"; import { getStorageProvider, resolvePayloadValue, } from "@local/hash-backend-utils/flows/payload-storage"; -import { - mergePropertyObjectAndMetadata, - queryEntities, -} from "@local/hash-graph-sdk/entity"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; -import { validatePropertyChanges } from "@local/hash-isomorphic-utils/flows/property-values"; +import { validatePropertyPatches } from "@local/hash-isomorphic-utils/flows/property-values"; import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { StatusCode } from "@local/status"; import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; import { getFlowContext } from "../shared/get-flow-context.js"; import { graphApiClient } from "../shared/graph-api-client.js"; -import { checkPropertyValues } from "./shared/check-property-values.js"; -import { getEntityUpdate } from "./shared/graph-requests.js"; import { getSavingActorId, retrySave } from "./shared/save-entity.js"; import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; @@ -29,26 +23,22 @@ const invalidArgument = (message: string) => ({ }); /** - * Sets and removes the given properties of an entity the user running the flow can edit, leaving its other properties - * as they are. A value replaces a list property's whole list. An entity ID with a draft ID updates that draft, which - * stays a draft. + * Applies property patches to an entity the user running the flow can edit, leaving its other properties as they are. + * The graph validates the entity the patches leave. An entity ID with a draft ID updates that draft, which stays a + * draft. */ export const updateEntityAction: FlowActionActivity<"updateEntity"> = async ({ inputs, }) => { - const { entityId, properties, removeProperties } = - getSimplifiedFlowActionInputs({ - inputs, - actionType: "updateEntity", - }); - - const changesError = validatePropertyChanges({ - properties, - removeProperties, + const { entityId, propertyPatches } = getSimplifiedFlowActionInputs({ + inputs, + actionType: "updateEntity", }); - if (changesError) { - return invalidArgument(changesError); + const patchesError = validatePropertyPatches(propertyPatches); + + if (patchesError) { + return invalidArgument(patchesError); } const { flowEntityId, stepId, userAuthentication, webId, workflowId } = @@ -111,91 +101,19 @@ export const updateEntityAction: FlowActionActivity<"updateEntity"> = async ({ }; } - const propertiesWithMetadata = mergePropertyObjectAndMetadata( - properties ?? {}, - undefined, - ); - - /* A property the entity doesn't have is skipped, e.g. one an earlier attempt of the step removed. */ - const removedBaseUrls = new Set( - removeProperties?.filter((baseUrl) => - Object.hasOwn(existingEntity.properties, baseUrl), - ), - ); - - const succeeded = () => ({ - code: StatusCode.Ok, - contents: [ - { - outputs: [ - { - outputName: "entityId" as const, - payload: { kind: "EntityId" as const, value: resolvedEntityId }, - }, - ], - }, - ], - }); - - if (properties === undefined && removedBaseUrls.size === 0) { - return succeeded(); - } - - /* - * Removing a property from a live entity may leave out a required one, which only the whole entity shows. Its other - * values keep their metadata, so that their data types aren't inferred again. - */ - const checksWholeEntity = removedBaseUrls.size > 0 && !draftId; - - const valuesError = - checksWholeEntity || properties !== undefined - ? await checkPropertyValues({ - actorId: userAuthentication.actorId, - entityTypeIds: existingEntity.metadata.entityTypeIds, - properties: checksWholeEntity - ? { - value: { - ...Object.fromEntries( - typedEntries( - existingEntity.propertiesWithMetadata.value, - ).filter(([baseUrl]) => !removedBaseUrls.has(baseUrl)), - ), - ...propertiesWithMetadata.value, - }, - } - : propertiesWithMetadata, - requiredProperties: checksWholeEntity, - }) - : undefined; - - if (valuesError) { - return invalidArgument(valuesError); - } - const savingActorId = await getSavingActorId({ actorId: userAuthentication.actorId, webId, isAiGenerated: false, }); - const { patchOperations } = getEntityUpdate({ - existingEntity, - newPropertiesWithMetadata: propertiesWithMetadata, - }); - try { await retrySave(() => existingEntity.patch( graphApiClient, { actorId: savingActorId }, { - propertyPatches: [ - ...patchOperations, - ...[...removedBaseUrls].map((baseUrl) => ({ - op: "remove" as const, - path: [baseUrl], - })), - ], + propertyPatches, draft: !!draftId, provenance: { actorType: "machine", @@ -212,5 +130,17 @@ export const updateEntityAction: FlowActionActivity<"updateEntity"> = async ({ }; } - return succeeded(); + return { + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: resolvedEntityId }, + }, + ], + }, + ], + }; }; diff --git a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts index 9d200d49790..bc0df67eaf3 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts +++ b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts @@ -12,7 +12,6 @@ const unsupportedPayloadKinds = [ /** @todo support ActorType to vary Sheet output formatting */ "ActorType", "Aggregation", - "BaseUrl", "DateTime", "Duration", /** @todo support EntityId – requires updating the EntitySelector */ @@ -21,7 +20,8 @@ const unsupportedPayloadKinds = [ "FailedEntityProposal", "FilterCondition", "PersistedEntityMetadata", - "PropertyValues", + "PropertyObjectWithMetadata", + "PropertyPatch", "ProposedEntity", "ProposedEntityWithResolvedLinks", "SortKey", diff --git a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts index 5880e274671..97dc7cf7dba 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts @@ -2,8 +2,8 @@ import { checkAggregationInput } from "./aggregation.js"; import { checkConditionInput, filterableKinds } from "./filter-condition.js"; import { maxEntitiesPerStep } from "./limits.js"; import { - checkPropertyValuesInput, - checkUpdateEntityInputs, + checkPropertiesWithMetadataInput, + checkPropertyPatchesInput, } from "./property-values.js"; import { checkSortKeyInput } from "./sort-key.js"; import { checkTakeCountInput } from "./take-count.js"; @@ -1052,10 +1052,10 @@ const aiFlowActionDefinitionsAsConst = { array: false, }, { - oneOfPayloadKinds: ["PropertyValues"], + oneOfPayloadKinds: ["PropertyObjectWithMetadata"], name: "properties", description: - "The entity's property values, keyed by property type base URL. A list property takes the whole list.", + "The entity's properties with their metadata, as the graph takes them: values keyed by property type base URL, each giving the ID of its data type.", required: true, array: false, }, @@ -1081,7 +1081,7 @@ const aiFlowActionDefinitionsAsConst = { required: true, }, ], - check: checkPropertyValuesInput, + check: checkPropertiesWithMetadataInput, }, updateEntity: { actionDefinitionId: "updateEntity", @@ -1099,19 +1099,11 @@ const aiFlowActionDefinitionsAsConst = { array: false, }, { - oneOfPayloadKinds: ["PropertyValues"], - name: "properties", - description: - "The new values of the properties to set, keyed by property type base URL. A list property's value replaces the whole list. Optional if properties are removed.", - required: false, - array: false, - }, - { - oneOfPayloadKinds: ["BaseUrl"], - name: "removeProperties", + oneOfPayloadKinds: ["PropertyPatch"], + name: "propertyPatches", description: - "The base URLs of the properties to remove. A property the entity doesn't have is skipped. Optional if properties are set.", - required: false, + "The changes to make, in order, as the graph takes them. Each changes the whole property at its path, a property type base URL: add sets it, replacing any value it has; replace changes one the entity has; remove removes it if the entity has it. Added and replaced values give the ID of their data type.", + required: true, array: true, }, ], @@ -1124,7 +1116,7 @@ const aiFlowActionDefinitionsAsConst = { required: true, }, ], - check: checkUpdateEntityInputs, + check: checkPropertyPatchesInput, }, takeFirst: { actionDefinitionId: "takeFirst", diff --git a/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts index 1d27930aa03..44a4a49f261 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/evaluate-filter.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { evaluateFilter } from "./evaluate-filter.js"; import type { FilterValue, ScalarFilterCondition } from "./evaluate-filter.js"; -import type { BaseUrl, EntityId } from "@blockprotocol/type-system"; +import type { EntityId } from "@blockprotocol/type-system"; const entityId = "00000000-0000-4000-8000-000000000001~00000000-0000-4000-8000-000000000002" as EntityId; @@ -23,10 +23,6 @@ const values: FilterValue[] = [ kind: "VersionedUrl", value: "https://example.com/types/entity-type/company/v/1", }, - { - kind: "BaseUrl", - value: "https://example.com/types/property-type/name/" as BaseUrl, - }, ]; describe("evaluateFilter", () => { @@ -231,7 +227,6 @@ describe("evaluateFilter", () => { ActorType: [...notText], GoogleAccountId: [...notText], VersionedUrl: [...notText], - BaseUrl: [...notText], }; it.each(unsupportedOperators[value.kind])("rejects %s", (operator) => { diff --git a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts index 1722c0cbff1..d15271b9187 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts @@ -1,122 +1,147 @@ -import { validateBaseUrl } from "@blockprotocol/type-system"; +import { + validateBaseUrl, + validateVersionedUrl, +} from "@blockprotocol/type-system"; import type { ActionStepCheck } from "./types.js"; -import type { BaseUrl, PropertyObject } from "@blockprotocol/type-system"; - -/** - * Values for some of an entity's properties, keyed by property type base URL, e.g. to create an entity with or to - * update one with. A list property's value is the whole list. - * - * Only the shape is checked when the flow is defined, because flows are validated without access to the ontology. The - * values are checked against the entity type when the step runs. +import type { + BaseUrl, + PropertyObjectWithMetadata, + PropertyPatchOperation, +} from "@blockprotocol/type-system"; + +/* + * Properties are given in the graph's own terms, so that the graph validates them when it saves the entity. Only their + * shape is checked here, because flows are validated without access to the ontology. */ -export type PropertyValues = PropertyObject; -const isPropertyBaseUrl = (value: unknown) => +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isPropertyBaseUrl = (value: unknown): value is BaseUrl => typeof value === "string" && validateBaseUrl(value).type === "Ok"; -/** Checks the shape of property values, returning why it's invalid. */ -export const validatePropertyValues = ( - values: PropertyValues, -): string | undefined => { - /* Persisted definitions and flow inputs may be malformed. */ - if ( - typeof values !== "object" || - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - values === null || - Array.isArray(values) - ) { - return "Property values must be an object keyed by property type base URL."; +/** + * Whether a property gives the data type ID of each of its values, including in lists and property objects. Without + * one, the graph infers it, which it can't do for a property that allows more than one data type, or only an abstract + * one. As in the graph, a value is told apart from a list or property object by its metadata having a data type ID. + */ +const hasDataTypeIds = (property: unknown): boolean => { + if (!isRecord(property)) { + return false; } - const keys = Object.keys(values); + const { value, metadata } = property; - if (keys.length === 0) { - return "Property values must give a value for at least one property."; + if (isRecord(metadata) && Object.hasOwn(metadata, "dataTypeId")) { + return ( + typeof metadata.dataTypeId === "string" && + validateVersionedUrl(metadata.dataTypeId).type === "Ok" + ); } - const invalidKey = keys.find((key) => !isPropertyBaseUrl(key)); - - if (invalidKey !== undefined) { - return `${JSON.stringify(invalidKey)} is not a property type base URL.`; + if (Array.isArray(value)) { + return value.every(hasDataTypeIds); } - return undefined; + return isRecord(value) && Object.values(value).every(hasDataTypeIds); }; -/** Checks a list of properties to remove from an entity, returning why it's invalid. */ -export const validatePropertyRemovals = ( - baseUrls: BaseUrl[], +const notABaseUrl = (value: unknown) => + `${JSON.stringify(value)} is not a property type base URL.`; + +const missingDataTypeIds = (baseUrl: BaseUrl) => + `Property ${baseUrl} must give the data type ID of each of its values.`; + +/** Checks the shape of an entity's properties with their metadata, returning why it's invalid. */ +export const validatePropertiesWithMetadata = ( + properties: PropertyObjectWithMetadata, ): string | undefined => { /* Persisted definitions and flow inputs may be malformed. */ - if (!Array.isArray(baseUrls)) { - return "Properties to remove must be a list of property type base URLs."; + if (!isRecord(properties) || !isRecord(properties.value)) { + return "Properties must be an object whose value is keyed by property type base URL."; } - const invalidBaseUrl = baseUrls.find( - (baseUrl) => !isPropertyBaseUrl(baseUrl), - ); + const entries = Object.entries(properties.value); + + if (entries.length === 0) { + return "Properties must give a value for at least one property."; + } + + for (const [baseUrl, property] of entries) { + if (!isPropertyBaseUrl(baseUrl)) { + return notABaseUrl(baseUrl); + } - if (invalidBaseUrl !== undefined) { - return `${JSON.stringify(invalidBaseUrl)} is not a property type base URL.`; + if (!hasDataTypeIds(property)) { + return missingDataTypeIds(baseUrl); + } } return undefined; }; -const noChanges = "Give property values to set, properties to remove, or both."; - -const setAndRemoved = (baseUrl: BaseUrl) => - `Property ${baseUrl} can't be both set and removed.`; +const patchOps: PropertyPatchOperation["op"][] = ["add", "remove", "replace"]; /** - * Checks the changes an update makes to an entity: values to set, properties to remove, or both. Returns why they're - * invalid. + * Checks the shape of patches to an entity's properties, returning why they're invalid. Each patch changes a whole + * property: a patch inside a list could change it twice if the step is retried, e.g. adding an item at an index. */ -export const validatePropertyChanges = ({ - properties, - removeProperties, -}: { - properties?: PropertyValues; - removeProperties?: BaseUrl[]; -}): string | undefined => { - if (properties === undefined && !removeProperties?.length) { - return noChanges; +export const validatePropertyPatches = ( + patches: PropertyPatchOperation[], +): string | undefined => { + /* Persisted definitions and flow inputs may be malformed. */ + if (!Array.isArray(patches)) { + return "Property patches must be a list."; } - const error = - (properties === undefined - ? undefined - : validatePropertyValues(properties)) ?? - (removeProperties === undefined - ? undefined - : validatePropertyRemovals(removeProperties)); - - if (error) { - return error; + if (patches.length === 0) { + return "Give at least one property patch."; } - const bothSetAndRemoved = - properties && - removeProperties?.find((baseUrl) => Object.hasOwn(properties, baseUrl)); + for (const [index, patch] of patches.entries()) { + const position = `Property patch ${index + 1}`; + + if (!isRecord(patch) || !patchOps.includes(patch.op)) { + return `${position} must have an op of ${patchOps.join(", ")}.`; + } - return bothSetAndRemoved ? setAndRemoved(bothSetAndRemoved) : undefined; + const { path } = patch; + const baseUrl: unknown = Array.isArray(path) ? path[0] : undefined; + + if (!isPropertyBaseUrl(baseUrl)) { + return `${position} must have a path of a property type base URL.`; + } + + if (path.length > 1) { + return `${position} changes part of property ${baseUrl}, which isn't supported yet: give its whole value.`; + } + + if (patch.op !== "remove" && !hasDataTypeIds(patch.property)) { + return missingDataTypeIds(baseUrl); + } + } + + return undefined; }; -/** Checks a step's constant `properties` input when the flow is defined. */ -export const checkPropertyValuesInput: ActionStepCheck = ({ step, report }) => { +/** Checks a Create entity step's constant `properties` input when the flow is defined. */ +export const checkPropertiesWithMetadataInput: ActionStepCheck = ({ + step, + report, +}) => { const { properties } = step.inputs; /* A constant of another kind is reported as an incompatible connection. */ if ( properties?.kind !== "constant" || - properties.payload.kind !== "PropertyValues" + properties.payload.kind !== "PropertyObjectWithMetadata" ) { return; } - const error = validatePropertyValues( - properties.payload.value as PropertyValues, + const error = validatePropertiesWithMetadata( + properties.payload.value as PropertyObjectWithMetadata, ); if (error) { @@ -124,54 +149,26 @@ export const checkPropertyValuesInput: ActionStepCheck = ({ step, report }) => { } }; -/** - * Checks an Update entity step's inputs when the flow is defined: that it sets or removes something, and that its - * constant `properties` and `removeProperties` are valid and don't name the same property. - */ -export const checkUpdateEntityInputs: ActionStepCheck = (params) => { - const { step, report } = params; - const { properties, removeProperties } = step.inputs; - - if (!properties && !removeProperties) { - report("properties", noChanges); - return; - } - - checkPropertyValuesInput(params); +/** Checks an Update entity step's constant `propertyPatches` input when the flow is defined. */ +export const checkPropertyPatchesInput: ActionStepCheck = ({ + step, + report, +}) => { + const { propertyPatches } = step.inputs; /* A constant of another kind is reported as an incompatible connection. */ if ( - removeProperties?.kind !== "constant" || - removeProperties.payload.kind !== "BaseUrl" + propertyPatches?.kind !== "constant" || + propertyPatches.payload.kind !== "PropertyPatch" ) { return; } - const baseUrls = removeProperties.payload.value as BaseUrl[]; - - const removalsError = validatePropertyRemovals(baseUrls); - - if (removalsError) { - report("removeProperties", removalsError); - return; - } - - if (!properties && baseUrls.length === 0) { - report("removeProperties", noChanges); - return; - } - - const values = - properties?.kind === "constant" && - properties.payload.kind === "PropertyValues" && - !validatePropertyValues(properties.payload.value as PropertyValues) - ? (properties.payload.value as PropertyValues) - : undefined; - - const bothSetAndRemoved = - values && baseUrls.find((baseUrl) => Object.hasOwn(values, baseUrl)); + const error = validatePropertyPatches( + propertyPatches.payload.value as PropertyPatchOperation[], + ); - if (bothSetAndRemoved) { - report("removeProperties", setAndRemoved(bothSetAndRemoved)); + if (error) { + report("propertyPatches", error); } }; diff --git a/libs/@local/hash-isomorphic-utils/src/flows/types.ts b/libs/@local/hash-isomorphic-utils/src/flows/types.ts index 331dcf2b4a3..0531ff3d599 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/types.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/types.ts @@ -8,15 +8,15 @@ import type { } from "./action-definitions.js"; import type { Aggregation } from "./aggregation.js"; import type { FilterCondition } from "./filter-condition.js"; -import type { PropertyValues } from "./property-values.js"; import type { SortKey } from "./sort-key.js"; import type { ActorEntityUuid, - BaseUrl, EntityId, EntityUuid, PropertyObject, PropertyObjectMetadata, + PropertyObjectWithMetadata, + PropertyPatchOperation, ProvidedEntityEditionProvenance, Url, VersionedUrl, @@ -121,8 +121,6 @@ export type EntityQuery = Pick & export type PayloadKindValues = { ActorType: ActorTypeDataType; Aggregation: Aggregation; - /** A type's URL without its version, e.g. "https://example.com/types/property-type/name/" */ - BaseUrl: BaseUrl; Boolean: boolean; Date: string; // e.g. "2025-01-01" /** An ISO 8601 instant with `Z` or an offset, e.g. "2026-10-16T14:00:00Z" */ @@ -137,7 +135,10 @@ export type PayloadKindValues = { GoogleSheet: GoogleSheet; Number: number; PersistedEntityMetadata: PersistedEntityMetadata; - PropertyValues: PropertyValues; + /** An entity's properties with their metadata, as the graph takes them to create an entity */ + PropertyObjectWithMetadata: PropertyObjectWithMetadata; + /** A change to one of an entity's properties, as the graph takes it to update an entity */ + PropertyPatch: PropertyPatchOperation; ProposedEntity: ProposedEntity; ProposedEntityWithResolvedLinks: ProposedEntityWithResolvedLinks; SortKey: SortKey; @@ -178,7 +179,6 @@ export type PayloadKindTraits = export const payloadKindTraits = { ActorType: { primitive: "text" }, Aggregation: { primitive: null }, - BaseUrl: { primitive: "text" }, Boolean: { primitive: "boolean" }, Date: { primitive: "date" }, DateTime: { primitive: "datetime" }, @@ -192,7 +192,8 @@ export const payloadKindTraits = { GoogleSheet: { primitive: null, fields: true }, Number: { primitive: "number" }, PersistedEntityMetadata: { primitive: null, fields: true }, - PropertyValues: { primitive: null }, + PropertyObjectWithMetadata: { primitive: null }, + PropertyPatch: { primitive: null }, ProposedEntity: { primitive: null, fields: true, entity: true }, ProposedEntityWithResolvedLinks: { primitive: null, diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts index 785934043f9..e98f04ddc65 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts @@ -766,7 +766,7 @@ describe("validateFlowDefinition", () => { name: "of a kind that can't be sorted", subject: { kind: "field", path: ["url"], payloadKind: "WebPage" }, message: - "A sort key's subject must be of kind ActorType, BaseUrl, Boolean, Date, DateTime, EntityId, GoogleAccountId, Number, Text, VersionedUrl.", + "A sort key's subject must be of kind ActorType, Boolean, Date, DateTime, EntityId, GoogleAccountId, Number, Text, VersionedUrl.", }, { name: "that doesn't fit the items", @@ -794,107 +794,120 @@ describe("validateFlowDefinition", () => { }); }); - describe.each([ - { - actionDefinitionId: "createEntity", - inputs: { - entityTypeId: { - kind: "constant", - payload: { - kind: "VersionedUrl", - value: "https://example.com/types/entity-type/person/v/1", + describe("properties of createEntity steps", () => { + const nameUrl = "https://example.com/types/property-type/name/"; + const emailUrl = "https://example.com/types/property-type/email/"; + const text = { dataTypeId: "https://example.com/types/data-type/text/v/1" }; + + const errorsOf = (properties: unknown) => + validateFlowDefinition({ + ...baseDefinition, + inputs: [ + { + name: "properties", + payloadKind: "PropertyObjectWithMetadata", + array: false, + required: true, }, - }, - }, - }, - { - actionDefinitionId: "updateEntity", - inputs: { entityId: { kind: "flow-input", inputName: "person" } }, - }, - ])( - "properties of $actionDefinitionId steps", - ({ actionDefinitionId, inputs }) => { - const errorsOf = (properties: unknown) => - validateFlowDefinition({ - ...baseDefinition, - inputs: [ - { - name: "person", - payloadKind: "EntityId", - array: false, - required: true, - }, - { - name: "values", - payloadKind: "PropertyValues", - array: false, - required: true, - }, - ], - steps: [ - { - kind: "action", - stepId: "save", - actionDefinitionId, - description: "", - inputs: { ...inputs, properties }, + ], + steps: [ + { + kind: "action", + stepId: "create", + actionDefinitionId: "createEntity", + description: "", + inputs: { + entityTypeId: { + kind: "constant", + payload: { + kind: "VersionedUrl", + value: "https://example.com/types/entity-type/person/v/1", + }, + }, + properties, }, - ], - }).diagnostics.filter(({ severity }) => severity === "error"); + }, + ], + }).diagnostics.filter(({ severity }) => severity === "error"); - const constant = (value: unknown) => ({ - kind: "constant", - payload: { kind: "PropertyValues", value }, - }); + const constant = (value: unknown) => ({ + kind: "constant", + payload: { kind: "PropertyObjectWithMetadata", value }, + }); - it.each([ - { - name: "a constant", - properties: constant({ - "https://example.com/types/property-type/name/": "Ada", - "https://example.com/types/property-type/email/": [ - "ada@example.com", - ], - }), + it.each([ + { + name: "a constant with a data type ID for each value, including in a list", + properties: constant({ + value: { + [nameUrl]: { value: "Ada", metadata: text }, + [emailUrl]: { + value: [{ value: "ada@example.com", metadata: text }], + }, + }, + }), + }, + { + name: "a flow input, which is checked when the step runs", + properties: { kind: "flow-input", inputName: "properties" }, + }, + ])("accepts $name", ({ properties }) => { + expect(errorsOf(properties)).toEqual([]); + }); + + it.each([ + { + name: "properties without metadata", + value: { [nameUrl]: "Ada" }, + message: + "Properties must be an object whose value is keyed by property type base URL.", + }, + { + name: "no properties", + value: { value: {} }, + message: "Properties must give a value for at least one property.", + }, + { + name: "a property type versioned URL rather than a base URL", + value: { + value: { [`${nameUrl}v/1`]: { value: "Ada", metadata: text } }, }, - { - name: "a flow input, which is checked when the step runs", - properties: { kind: "flow-input", inputName: "values" }, + message: `"${nameUrl}v/1" is not a property type base URL.`, + }, + { + name: "a value without a data type ID", + value: { + value: { + [nameUrl]: { value: "Ada", metadata: { dataTypeId: null } }, + }, }, - ])("accepts $name", ({ properties }) => { - expect(errorsOf(properties)).toEqual([]); - }); - - it.each([ - { - name: "no values", - value: {}, - message: - "Property values must give a value for at least one property.", + message: `Property ${nameUrl} must give the data type ID of each of its values.`, + }, + { + name: "an item of a list without a data type ID", + value: { + value: { [emailUrl]: { value: [{ value: "ada@example.com" }] } }, }, + message: `Property ${emailUrl} must give the data type ID of each of its values.`, + }, + ])("rejects $name", ({ value, message }) => { + expect(errorsOf(constant(value))).toEqual([ { - name: "a property type versioned URL rather than a base URL", - value: { "https://example.com/types/property-type/name/v/1": "Ada" }, - message: - '"https://example.com/types/property-type/name/v/1" is not a property type base URL.', + severity: "error", + code: "invalidInput", + message, + path: ["steps", 0, "inputs", "properties"], }, - ])("rejects $name", ({ value, message }) => { - expect(errorsOf(constant(value))).toEqual([ - { - severity: "error", - code: "invalidInput", - message, - path: ["steps", 0, "inputs", "properties"], - }, - ]); - }); - }, - ); + ]); + }); + }); - describe("properties to remove of updateEntity steps", () => { + describe("property patches of updateEntity steps", () => { const nameUrl = "https://example.com/types/property-type/name/"; + const emailUrl = "https://example.com/types/property-type/email/"; + const text = { dataTypeId: "https://example.com/types/data-type/text/v/1" }; - const errorsOf = (inputs: Record) => + const errorsOf = (propertyPatches: unknown) => validateFlowDefinition({ ...baseDefinition, inputs: [ @@ -905,14 +918,8 @@ describe("validateFlowDefinition", () => { required: true, }, { - name: "values", - payloadKind: "PropertyValues", - array: false, - required: true, - }, - { - name: "removals", - payloadKind: "BaseUrl", + name: "patches", + payloadKind: "PropertyPatch", array: true, required: true, }, @@ -920,93 +927,93 @@ describe("validateFlowDefinition", () => { steps: [ { kind: "action", - stepId: "save", + stepId: "update", actionDefinitionId: "updateEntity", description: "", inputs: { entityId: { kind: "flow-input", inputName: "person" }, - ...inputs, + propertyPatches, }, }, ], }).diagnostics.filter(({ severity }) => severity === "error"); - const removals = (value: unknown) => ({ + const constant = (value: unknown) => ({ kind: "constant", - payload: { kind: "BaseUrl", value }, - }); - - const values = (value: unknown) => ({ - kind: "constant", - payload: { kind: "PropertyValues", value }, + payload: { kind: "PropertyPatch", value }, }); it.each([ { - name: "only properties to remove", - inputs: { removeProperties: removals([nameUrl]) }, - }, - { - name: "properties to remove from a flow input, which are checked when the step runs", - inputs: { - removeProperties: { kind: "flow-input", inputName: "removals" }, - }, - }, - { - name: "values to set and different properties to remove", - inputs: { - properties: values({ [nameUrl]: "Ada" }), - removeProperties: removals([ - "https://example.com/types/property-type/email/", - ]), - }, + name: "constant patches that add, replace and remove whole properties", + propertyPatches: constant([ + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: text }, + }, + { + op: "replace", + path: [emailUrl], + property: { value: [{ value: "ada@example.com", metadata: text }] }, + }, + { + op: "remove", + path: ["https://example.com/types/property-type/age/"], + }, + ]), }, { - name: "no properties to remove alongside values to set", - inputs: { - properties: { kind: "flow-input", inputName: "values" }, - removeProperties: removals([]), - }, + name: "a flow input, which is checked when the step runs", + propertyPatches: { kind: "flow-input", inputName: "patches" }, }, - ])("accepts $name", ({ inputs }) => { - expect(errorsOf(inputs)).toEqual([]); + ])("accepts $name", ({ propertyPatches }) => { + expect(errorsOf(propertyPatches)).toEqual([]); }); it.each([ { - name: "neither values to set nor properties to remove", - inputs: {}, - input: "properties", - message: "Give property values to set, properties to remove, or both.", + name: "no patches", + value: [], + message: "Give at least one property patch.", }, { - name: "an empty list of properties to remove alone", - inputs: { removeProperties: removals([]) }, - input: "removeProperties", - message: "Give property values to set, properties to remove, or both.", + name: "an op the graph doesn't have", + value: [{ op: "move", path: [nameUrl] }], + message: "Property patch 1 must have an op of add, remove, replace.", }, { - name: "a property to remove named by a versioned URL", - inputs: { removeProperties: removals([`${nameUrl}v/1`]) }, - input: "removeProperties", - message: `"${nameUrl}v/1" is not a property type base URL.`, + name: "a path of a property type versioned URL", + value: [{ op: "remove", path: [`${nameUrl}v/1`] }], + message: + "Property patch 1 must have a path of a property type base URL.", }, { - name: "a property both set and removed", - inputs: { - properties: values({ [nameUrl]: "Ada" }), - removeProperties: removals([nameUrl]), - }, - input: "removeProperties", - message: `Property ${nameUrl} can't be both set and removed.`, + name: "a path inside a property's value", + value: [ + { op: "remove", path: [nameUrl] }, + { op: "remove", path: [emailUrl, 0] }, + ], + message: `Property patch 2 changes part of property ${emailUrl}, which isn't supported yet: give its whole value.`, + }, + { + name: "a value without a data type ID", + value: [ + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: { dataTypeId: null } }, + }, + ], + message: `Property ${nameUrl} must give the data type ID of each of its values.`, }, - ])("rejects $name", ({ inputs, input, message }) => { - expect(errorsOf(inputs)).toEqual([ + ])("rejects $name", ({ value, message }) => { + expect(errorsOf(constant(value))).toEqual([ { severity: "error", code: "invalidInput", message, - path: ["steps", 0, "inputs", input], + path: ["steps", 0, "inputs", "propertyPatches"], }, ]); }); From 4422f4dad0d286f1c151c24c4b2e698e0e27bff3 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 20 Oct 2026 13:42:13 +0200 Subject: [PATCH 03/10] Say Create entity's property metadata may give provenance and confidence Co-authored-by: Cursor --- .../hash-isomorphic-utils/src/flows/action-definitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts index 97dc7cf7dba..20657832f9c 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts @@ -1055,7 +1055,7 @@ const aiFlowActionDefinitionsAsConst = { oneOfPayloadKinds: ["PropertyObjectWithMetadata"], name: "properties", description: - "The entity's properties with their metadata, as the graph takes them: values keyed by property type base URL, each giving the ID of its data type.", + "The entity's properties with their metadata, as the graph takes them: values keyed by property type base URL, each giving the ID of its data type and optionally its provenance and confidence.", required: true, array: false, }, From 5f3d58f72fa5deffc71396be59de61d4c884b650 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 21 Oct 2026 16:33:32 +0200 Subject: [PATCH 04/10] Rename notABaseUrl and missingDataTypeIds to say they return messages Co-authored-by: Cursor --- .../hash-isomorphic-utils/src/flows/property-values.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts index d15271b9187..3b842ac2b51 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts @@ -47,10 +47,10 @@ const hasDataTypeIds = (property: unknown): boolean => { return isRecord(value) && Object.values(value).every(hasDataTypeIds); }; -const notABaseUrl = (value: unknown) => +const notABaseUrlMessage = (value: unknown) => `${JSON.stringify(value)} is not a property type base URL.`; -const missingDataTypeIds = (baseUrl: BaseUrl) => +const missingDataTypeIdsMessage = (baseUrl: BaseUrl) => `Property ${baseUrl} must give the data type ID of each of its values.`; /** Checks the shape of an entity's properties with their metadata, returning why it's invalid. */ @@ -70,11 +70,11 @@ export const validatePropertiesWithMetadata = ( for (const [baseUrl, property] of entries) { if (!isPropertyBaseUrl(baseUrl)) { - return notABaseUrl(baseUrl); + return notABaseUrlMessage(baseUrl); } if (!hasDataTypeIds(property)) { - return missingDataTypeIds(baseUrl); + return missingDataTypeIdsMessage(baseUrl); } } @@ -118,7 +118,7 @@ export const validatePropertyPatches = ( } if (patch.op !== "remove" && !hasDataTypeIds(patch.property)) { - return missingDataTypeIds(baseUrl); + return missingDataTypeIdsMessage(baseUrl); } } From 5ee4a8d75f5480a1fa6d274750e0c15ad1937425 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 21 Oct 2026 16:34:19 +0200 Subject: [PATCH 05/10] Allow creating an entity without properties, as many links have none Co-authored-by: Cursor --- .../create-entity-action.test.ts | 18 +++++++++++++----- .../src/flows/property-values.ts | 8 +------- .../src/flows/validate-flow-definition.test.ts | 9 ++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts index a8ff320a925..06195c5e542 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -161,6 +161,19 @@ describe("createEntityAction", () => { ); }); + it("creates an entity without properties", async () => { + mockCreated(`${webId}~00000000-0000-4000-8000-000000000002` as EntityId); + + expect(await run({ properties: { value: {} } })).toMatchObject({ + code: StatusCode.Ok, + }); + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ properties: { value: {} } }), + ); + }); + it.each([ { name: "an entity type ID that isn't a versioned URL", @@ -171,11 +184,6 @@ describe("createEntityAction", () => { message: '"https://example.com/types/entity-type/person/" is not an entity type ID.', }, - { - name: "no properties", - params: { properties: { value: {} } }, - message: "Properties must give a value for at least one property.", - }, { name: "a value without a data type ID", params: { diff --git a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts index 3b842ac2b51..ae079c55f93 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts @@ -62,13 +62,7 @@ export const validatePropertiesWithMetadata = ( return "Properties must be an object whose value is keyed by property type base URL."; } - const entries = Object.entries(properties.value); - - if (entries.length === 0) { - return "Properties must give a value for at least one property."; - } - - for (const [baseUrl, property] of entries) { + for (const [baseUrl, property] of Object.entries(properties.value)) { if (!isPropertyBaseUrl(baseUrl)) { return notABaseUrlMessage(baseUrl); } diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts index e98f04ddc65..75bb808aeeb 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts @@ -847,6 +847,10 @@ describe("validateFlowDefinition", () => { }, }), }, + { + name: "a constant with no properties", + properties: constant({ value: {} }), + }, { name: "a flow input, which is checked when the step runs", properties: { kind: "flow-input", inputName: "properties" }, @@ -862,11 +866,6 @@ describe("validateFlowDefinition", () => { message: "Properties must be an object whose value is keyed by property type base URL.", }, - { - name: "no properties", - value: { value: {} }, - message: "Properties must give a value for at least one property.", - }, { name: "a property type versioned URL rather than a base URL", value: { From 4ca7e4bee6ce42af08b8c43f429a06936853c5c7 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 21 Oct 2026 16:35:26 +0200 Subject: [PATCH 06/10] Let Create entity take more than one entity type Co-authored-by: Cursor --- .../create-entity-action.test.ts | 30 ++++++++++++++++--- .../flow-activities/create-entity-action.ts | 25 +++++++++++----- .../src/flows/action-definitions.ts | 8 ++--- .../flows/validate-flow-definition.test.ts | 4 +-- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts index 06195c5e542..d95c624a764 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -46,6 +46,8 @@ vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ const personType = "https://example.com/types/entity-type/person/v/1" as VersionedUrl; +const employeeType = + "https://example.com/types/entity-type/employee/v/1" as VersionedUrl; const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; const text = { @@ -57,17 +59,17 @@ const ada = { } satisfies PropertyObjectWithMetadata; const run = (params: { - entityTypeId?: string; + entityTypeIds?: string[]; properties: PropertyObjectWithMetadata; draft?: boolean; }) => createEntityAction({ inputs: [ { - inputName: "entityTypeId", + inputName: "entityTypeIds", payload: { kind: "VersionedUrl", - value: (params.entityTypeId ?? personType) as VersionedUrl, + value: (params.entityTypeIds ?? [personType]) as VersionedUrl[], }, }, { @@ -174,11 +176,31 @@ describe("createEntityAction", () => { ); }); + it("creates an entity with every type given", async () => { + mockCreated(`${webId}~00000000-0000-4000-8000-000000000002` as EntityId); + + await run({ entityTypeIds: [personType, employeeType], properties: ada }); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ entityTypeIds: [personType, employeeType] }), + ); + }); + it.each([ + { + name: "no entity type IDs", + params: { entityTypeIds: [], properties: ada }, + message: "Give at least one entity type ID.", + }, { name: "an entity type ID that isn't a versioned URL", params: { - entityTypeId: "https://example.com/types/entity-type/person/", + entityTypeIds: [ + personType, + "https://example.com/types/entity-type/person/", + ], properties: ada, }, message: diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts index 211b965df14..3a6ef21c8cf 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts @@ -25,22 +25,31 @@ const invalidArgument = (message: string) => ({ }); /** - * Creates an entity of a type in the flow's web, with the given properties and their metadata, as the web's machine on - * behalf of the user running the flow. The graph validates the entity; a draft may leave required properties out. + * Creates an entity of the given types in the flow's web, with the given properties and their metadata, as the web's + * machine on behalf of the user running the flow. The graph validates the entity; a draft may leave required properties + * out. */ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ inputs, }) => { const { - entityTypeId, + entityTypeIds, properties, draft = false, } = getSimplifiedFlowActionInputs({ inputs, actionType: "createEntity" }); - if (validateVersionedUrl(entityTypeId).type !== "Ok") { - return invalidArgument( - `${JSON.stringify(entityTypeId)} is not an entity type ID.`, - ); + const [firstEntityTypeId, ...otherEntityTypeIds] = entityTypeIds; + + if (!firstEntityTypeId) { + return invalidArgument("Give at least one entity type ID."); + } + + for (const entityTypeId of entityTypeIds) { + if (validateVersionedUrl(entityTypeId).type !== "Ok") { + return invalidArgument( + `${JSON.stringify(entityTypeId)} is not an entity type ID.`, + ); + } } const propertiesError = validatePropertiesWithMetadata(properties); @@ -97,7 +106,7 @@ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ { webId, entityUuid, - entityTypeIds: [entityTypeId], + entityTypeIds: [firstEntityTypeId, ...otherEntityTypeIds], properties, draft, provenance: { diff --git a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts index 20657832f9c..73b9c661648 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts @@ -1041,15 +1041,15 @@ const aiFlowActionDefinitionsAsConst = { actionDefinitionId: "createEntity", name: "Create Entity", description: - "Create an entity of a type in the web the flow runs in, with values for some of its properties.", + "Create an entity of one or more types in the web the flow runs in, with values for some of its properties.", kind: "action", inputs: [ { oneOfPayloadKinds: ["VersionedUrl"], - name: "entityTypeId", - description: "The type of the entity to create.", + name: "entityTypeIds", + description: "The types of the entity to create.", required: true, - array: false, + array: true, }, { oneOfPayloadKinds: ["PropertyObjectWithMetadata"], diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts index 75bb808aeeb..cf2f70ba5e2 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts @@ -817,11 +817,11 @@ describe("validateFlowDefinition", () => { actionDefinitionId: "createEntity", description: "", inputs: { - entityTypeId: { + entityTypeIds: { kind: "constant", payload: { kind: "VersionedUrl", - value: "https://example.com/types/entity-type/person/v/1", + value: ["https://example.com/types/entity-type/person/v/1"], }, }, properties, From f4f7d4b93e0ef2fe4713fd936aeb7298d886f2e3 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 23 Oct 2026 14:02:08 +0200 Subject: [PATCH 07/10] Give the property object and property patch kinds colours in flow nets Co-authored-by: Cursor --- .../flow-petri-net/src/compile-flow-definition/shared/colors.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts b/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts index 2f463792f4e..054c4c381d5 100644 --- a/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts +++ b/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts @@ -63,6 +63,8 @@ const payloadKindDisplayColors: Record = { PersistedEntityMetadata: "#10B981", ProposedEntity: "#22C55E", ProposedEntityWithResolvedLinks: "#16A34A", + PropertyObjectWithMetadata: "#15803D", + PropertyPatch: "#0D9488", FailedEntityProposal: "#B91C1C", Boolean: "#F59E0B", Number: "#EA580C", From e525ca8c74d83f4f15148965624f1e8e88b38416 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 23 Oct 2026 16:26:51 +0200 Subject: [PATCH 08/10] Check the user can instantiate Create entity's types before saving as the web's machine Co-authored-by: Cursor --- .../create-entity-action.test.ts | 30 +++++++++++++++++++ .../flow-activities/create-entity-action.ts | 24 +++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts index d95c624a764..654d7c9a190 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { hasPermissionForEntityTypes } from "@local/hash-graph-sdk/entity-type"; import { StatusCode } from "@local/status"; import { createEntityAction } from "./create-entity-action.js"; @@ -43,6 +44,10 @@ vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ HashEntity: { create }, queryEntities: vi.fn(), })); +vi.mock("@local/hash-graph-sdk/entity-type", async (importOriginal) => ({ + ...(await importOriginal()), + hasPermissionForEntityTypes: vi.fn(), +})); const personType = "https://example.com/types/entity-type/person/v/1" as VersionedUrl; @@ -103,6 +108,11 @@ describe("createEntityAction", () => { entities: [], cursor: null, } as unknown as QueryEntitiesResponse); + vi.mocked(hasPermissionForEntityTypes) + .mockReset() + .mockImplementation((_graphApi, _authentication, { entityTypeIds }) => + Promise.resolve(entityTypeIds), + ); }); it("creates an entity of the type with the properties as given, in the flow's web, and outputs its ID", async () => { @@ -230,6 +240,26 @@ describe("createEntityAction", () => { }, ); + it("fails for a type the user running the flow can't instantiate, e.g. an actor type, without creating anything", async () => { + vi.mocked(hasPermissionForEntityTypes).mockResolvedValueOnce([personType]); + + expect( + await run({ entityTypeIds: [personType, employeeType], properties: ada }), + ).toEqual({ + code: StatusCode.PermissionDenied, + message: `The user running the flow can't create entities of type ${employeeType}.`, + contents: [], + }); + + expect(hasPermissionForEntityTypes).toHaveBeenCalledWith( + {}, + { actorId: "user" }, + { entityTypeIds: [personType, employeeType], action: "instantiate" }, + ); + expect(queryEntities).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + it("fails if the graph doesn't save the entity, e.g. because its properties don't fit the type", async () => { create.mockRejectedValueOnce(new Error("Entity validation failed")); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts index 3a6ef21c8cf..d91b51ffead 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts @@ -3,6 +3,7 @@ import { validateVersionedUrl, } from "@blockprotocol/type-system"; import { HashEntity, queryEntities } from "@local/hash-graph-sdk/entity"; +import { hasPermissionForEntityTypes } from "@local/hash-graph-sdk/entity-type"; import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; import { validatePropertiesWithMetadata } from "@local/hash-isomorphic-utils/flows/property-values"; import { generateUuidFromName } from "@local/hash-isomorphic-utils/generate-uuid"; @@ -61,6 +62,29 @@ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ const { flowEntityId, runId, stepId, userAuthentication, webId } = await getFlowContext(); + /* + * The web's machine saves the entity, and may instantiate types the user can't, so the user's own permission to + * instantiate each type, and the types it inherits from, is checked here. + */ + const instantiableEntityTypeIds = new Set( + await hasPermissionForEntityTypes(graphApiClient, userAuthentication, { + entityTypeIds, + action: "instantiate", + }), + ); + + const forbiddenEntityTypeIds = entityTypeIds.filter( + (entityTypeId) => !instantiableEntityTypeIds.has(entityTypeId), + ); + + if (forbiddenEntityTypeIds.length > 0) { + return { + code: StatusCode.PermissionDenied, + message: `The user running the flow can't create entities of type ${forbiddenEntityTypeIds.join(", ")}.`, + contents: [], + }; + } + const savingActorId = await getSavingActorId({ actorId: userAuthentication.actorId, webId, From e6e286db65b1a84550fc3d39f0e0864a60cd2135 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 23 Oct 2026 16:26:52 +0200 Subject: [PATCH 09/10] Reject property patches that change the same property twice, which fail on retry Co-authored-by: Cursor --- .../flow-activities/update-entity-action.test.ts | 9 +++++++++ .../src/flows/property-values.ts | 12 +++++++++++- .../src/flows/validate-flow-definition.test.ts | 12 ++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts index 36fe3c75120..38f7eecea0a 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts @@ -242,6 +242,15 @@ describe("updateEntityAction", () => { propertyPatches: [{ op: "remove", path: [emailUrl, 0] }], message: `Property patch 1 changes part of property ${emailUrl}, which isn't supported yet: give its whole value.`, }, + { + name: "two patches to the same property, which fail if applied again", + entityId: entityIdOf(1), + propertyPatches: [ + { ...setName, op: "replace" }, + { op: "remove", path: [nameUrl] }, + ], + message: `Property patch 2 changes property ${nameUrl} again: give one patch per property.`, + }, { name: "a value without a data type ID", entityId: entityIdOf(1), diff --git a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts index ae079c55f93..364fcc607aa 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts @@ -79,7 +79,9 @@ const patchOps: PropertyPatchOperation["op"][] = ["add", "remove", "replace"]; /** * Checks the shape of patches to an entity's properties, returning why they're invalid. Each patch changes a whole - * property: a patch inside a list could change it twice if the step is retried, e.g. adding an item at an index. + * property, and no two change the same one, so that applying them again if the step is retried leaves the same + * properties: a patch inside a list could add an item twice, and replacing a property then removing it fails once + * it's gone. */ export const validatePropertyPatches = ( patches: PropertyPatchOperation[], @@ -93,6 +95,8 @@ export const validatePropertyPatches = ( return "Give at least one property patch."; } + const patchedBaseUrls = new Set(); + for (const [index, patch] of patches.entries()) { const position = `Property patch ${index + 1}`; @@ -111,6 +115,12 @@ export const validatePropertyPatches = ( return `${position} changes part of property ${baseUrl}, which isn't supported yet: give its whole value.`; } + if (patchedBaseUrls.has(baseUrl)) { + return `${position} changes property ${baseUrl} again: give one patch per property.`; + } + + patchedBaseUrls.add(baseUrl); + if (patch.op !== "remove" && !hasDataTypeIds(patch.property)) { return missingDataTypeIdsMessage(baseUrl); } diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts index cf2f70ba5e2..ab39150875e 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts @@ -995,6 +995,18 @@ describe("validateFlowDefinition", () => { ], message: `Property patch 2 changes part of property ${emailUrl}, which isn't supported yet: give its whole value.`, }, + { + name: "two patches to the same property, which fail if applied again", + value: [ + { + op: "replace", + path: [nameUrl], + property: { value: "Ada", metadata: text }, + }, + { op: "remove", path: [nameUrl] }, + ], + message: `Property patch 2 changes property ${nameUrl} again: give one patch per property.`, + }, { name: "a value without a data type ID", value: [ From 6bf09f9006f4a13357e77a656e2739452fe45488 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 23 Oct 2026 16:33:03 +0200 Subject: [PATCH 10/10] Say a missing entity type may be why Create entity can't instantiate it, and Update entity takes one patch per property Co-authored-by: Cursor --- .../activities/flow-activities/create-entity-action.test.ts | 2 +- .../src/activities/flow-activities/create-entity-action.ts | 5 ++++- .../hash-isomorphic-utils/src/flows/action-definitions.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts index 654d7c9a190..23904d27893 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -247,7 +247,7 @@ describe("createEntityAction", () => { await run({ entityTypeIds: [personType, employeeType], properties: ada }), ).toEqual({ code: StatusCode.PermissionDenied, - message: `The user running the flow can't create entities of type ${employeeType}.`, + message: `Entity type ${employeeType} doesn't exist or can't be instantiated by the user running the flow.`, contents: [], }); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts index d91b51ffead..3f06ab279aa 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts @@ -80,7 +80,10 @@ export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ if (forbiddenEntityTypeIds.length > 0) { return { code: StatusCode.PermissionDenied, - message: `The user running the flow can't create entities of type ${forbiddenEntityTypeIds.join(", ")}.`, + message: + forbiddenEntityTypeIds.length === 1 + ? `Entity type ${forbiddenEntityTypeIds[0]} doesn't exist or can't be instantiated by the user running the flow.` + : `Entity types ${forbiddenEntityTypeIds.join(", ")} don't exist or can't be instantiated by the user running the flow.`, contents: [], }; } diff --git a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts index 73b9c661648..7245310d250 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts @@ -1102,7 +1102,7 @@ const aiFlowActionDefinitionsAsConst = { oneOfPayloadKinds: ["PropertyPatch"], name: "propertyPatches", description: - "The changes to make, in order, as the graph takes them. Each changes the whole property at its path, a property type base URL: add sets it, replacing any value it has; replace changes one the entity has; remove removes it if the entity has it. Added and replaced values give the ID of their data type.", + "The changes to make, in order, as the graph takes them. Each changes the whole property at its path, a property type base URL, and no two change the same property: add sets it, replacing any value it has; replace changes one the entity has; remove removes it if the entity has it. Added and replaced values give the ID of their data type.", required: true, array: true, },