From bb745c43cc99ac2453260f7c2f0a0de38befe27e Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Thu, 19 Mar 2026 14:28:04 -0600 Subject: [PATCH] fix: createTask with undefined input values fails to create task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: Setting a task input property to undefined (e.g. { prune: undefined }) to express "this key should be deleted" resulted in no task being created. JSON.stringify strips undefined values, so { prune: undefined } serialized as {}, and is_partial_of({}, any_config) always returns true — meaning input-not-matches saw a "match" and never activated the task. Fix (two parts): - SDK: coerce undefined to null in task input values before serialization, so they survive JSON.stringify and reach the Rust backend - Rust: treat null in a partial as matching a missing key in the full config, so tasks correctly deactivate when the key is already absent Assumption: null and undefined/absent are semantically equivalent for StartOS config values. Input specs produce concrete values (strings, numbers, booleans, objects, arrays) — null never appears as a meaningful distinct-from-absent value in real-world configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/src/util/serde.rs | 3 ++- sdk/base/lib/actions/index.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/core/src/util/serde.rs b/core/src/util/serde.rs index f5a4e3526..4c45813be 100644 --- a/core/src/util/serde.rs +++ b/core/src/util/serde.rs @@ -1456,7 +1456,8 @@ pub fn is_partial_of(partial: &Value, full: &Value) -> bool { if let Some(v_full) = full.get(k) { is_partial_of(v, v_full) } else { - false + // null in partial matches a missing key in full (both represent absence) + v.is_null() } }), (Value::Array(partial), Value::Array(full)) => partial diff --git a/sdk/base/lib/actions/index.ts b/sdk/base/lib/actions/index.ts index c4568ab1c..606f5b4d6 100644 --- a/sdk/base/lib/actions/index.ts +++ b/sdk/base/lib/actions/index.ts @@ -74,6 +74,18 @@ const _validate: T.Task = {} as TaskOptions & { severity: T.TaskSeverity } +/** Recursively converts undefined values to null so they survive JSON serialization */ +function undefinedToNull(obj: unknown): unknown { + if (obj === undefined) return null + if (obj === null || typeof obj !== 'object') return obj + if (Array.isArray(obj)) return obj.map(undefinedToNull) + const result: Record = {} + for (const [k, v] of Object.entries(obj)) { + result[k] = undefinedToNull(v) + } + return result +} + export const createTask = >(options: { effects: T.Effects packageId: T.PackageId @@ -83,8 +95,13 @@ export const createTask = >(options: { }) => { const request = options.options || {} const actionId = options.action.id + const input = + 'input' in request && request.input + ? { ...request.input, value: undefinedToNull(request.input.value) } + : (request as any).input const req = { ...request, + input, actionId, packageId: options.packageId, action: undefined,