Files
start-os/sdk/base/lib/actions/index.ts
Matt Hill bb745c43cc 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>
2026-03-19 14:28:04 -06:00

114 lines
3.2 KiB
TypeScript

import * as T from '../types'
import * as IST from '../actions/input/inputSpecTypes'
import { Action, ActionInfo } from './setupActions'
import { ExtractInputSpecType } from './input/builder/inputSpec'
export type RunActionInput<Input> =
| Input
| ((prev?: { spec: IST.InputSpec; value: Input | null }) => Input)
export const runAction = async <
Input extends Record<string, unknown>,
>(options: {
effects: T.Effects
// packageId?: T.PackageId
actionId: T.ActionId
input?: RunActionInput<Input>
}) => {
if (options.input) {
if (options.input instanceof Function) {
const prev = await options.effects.action.getInput({
// packageId: options.packageId,
actionId: options.actionId,
})
const input = options.input(
prev
? { spec: prev.spec as IST.InputSpec, value: prev.value as Input }
: undefined,
)
return options.effects.action.run({
// packageId: options.packageId,
actionId: options.actionId,
input,
})
} else {
return options.effects.action.run({
// packageId: options.packageId,
actionId: options.actionId,
input: options.input,
})
}
} else {
return options.effects.action.run({
// packageId: options.packageId,
actionId: options.actionId,
})
}
}
type GetActionInputType<A extends ActionInfo<T.ActionId, any>> =
A extends Action<T.ActionId, infer I> ? I : never
type TaskBase = {
reason?: string
replayId?: string
}
type TaskInput<T extends ActionInfo<T.ActionId, any>> = {
kind: 'partial'
value: T.DeepPartial<GetActionInputType<T>>
}
export type TaskOptions<T extends ActionInfo<T.ActionId, any>> = TaskBase &
(
| {
when?: Exclude<T.TaskTrigger, { condition: 'input-not-matches' }>
input?: TaskInput<T>
}
| {
when: T.TaskTrigger & { condition: 'input-not-matches' }
input: TaskInput<T>
}
)
const _validate: T.Task = {} as TaskOptions<any> & {
actionId: string
packageId: string
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: {
effects: T.Effects
packageId: T.PackageId
action: T
severity: T.TaskSeverity
options?: TaskOptions<T>
}) => {
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,
severity: options.severity,
replayId: request.replayId || `${options.packageId}:${actionId}`,
}
delete req.action
return options.effects.action.createTask(req)
}