fix: createTask with undefined input values fails to create task

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) <noreply@anthropic.com>
This commit is contained in:
Matt Hill
2026-03-19 14:28:04 -06:00
parent de9a7e4189
commit bb745c43cc
2 changed files with 19 additions and 1 deletions

View File

@@ -1456,7 +1456,8 @@ pub fn is_partial_of(partial: &Value, full: &Value) -> bool {
if let Some(v_full) = full.get(k) { if let Some(v_full) = full.get(k) {
is_partial_of(v, v_full) is_partial_of(v, v_full)
} else { } else {
false // null in partial matches a missing key in full (both represent absence)
v.is_null()
} }
}), }),
(Value::Array(partial), Value::Array(full)) => partial (Value::Array(partial), Value::Array(full)) => partial

View File

@@ -74,6 +74,18 @@ const _validate: T.Task = {} as TaskOptions<any> & {
severity: T.TaskSeverity 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<string, unknown> = {}
for (const [k, v] of Object.entries(obj)) {
result[k] = undefinedToNull(v)
}
return result
}
export const createTask = <T extends ActionInfo<T.ActionId, any>>(options: { export const createTask = <T extends ActionInfo<T.ActionId, any>>(options: {
effects: T.Effects effects: T.Effects
packageId: T.PackageId packageId: T.PackageId
@@ -83,8 +95,13 @@ export const createTask = <T extends ActionInfo<T.ActionId, any>>(options: {
}) => { }) => {
const request = options.options || {} const request = options.options || {}
const actionId = options.action.id 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 = { const req = {
...request, ...request,
input,
actionId, actionId,
packageId: options.packageId, packageId: options.packageId,
action: undefined, action: undefined,