diff --git a/src/backend/types.ts b/src/backend/types.ts
index 10f5eb3a7..6d9a1230a 100644
--- a/src/backend/types.ts
+++ b/src/backend/types.ts
@@ -642,6 +642,21 @@ export interface IDevWatcherConfig {
}>;
}
+/**
+ * System jobs config (`jobs`). An extension that runs jobs types its own keys
+ * by augmenting this interface.
+ *
+ * - `namespace` — keys this deployment's jobs and schedules, so deployments
+ * sharing one database or Redis never run each other's. Defaults to `domain`;
+ * one that can't be held falls back to `default` (`resolveJobsNamespace`).
+ * - `enabled` — `false` keeps this node from polling for jobs (logged at start);
+ * the rest of the fleet still runs them. Test servers turn it off.
+ */
+export interface IJobsConfig {
+ namespace?: string;
+ enabled?: boolean;
+}
+
/**
* Root config. Only `port` and `extensions` are mandatory; they are added in
* `IConfig` below. Every config field is declared here with a doc comment so
@@ -1291,6 +1306,9 @@ interface IConfigOptional {
internalSecret?: string;
};
+ /** System jobs (`services.jobs`); see {@link IJobsConfig}. */
+ jobs?: IJobsConfig;
+
/**
* Key-value sharing beyond watching.
*
diff --git a/src/backend/util/jobsNamespace.test.ts b/src/backend/util/jobsNamespace.test.ts
new file mode 100644
index 000000000..f66905d2d
--- /dev/null
+++ b/src/backend/util/jobsNamespace.test.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { IConfig } from '../types.js';
+import { resolveJobsNamespace } from './jobsNamespace.js';
+
+const config = (partial: Partial): IConfig => partial as IConfig;
+
+describe('resolveJobsNamespace', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('takes jobs.namespace first, then the domain, then "default"', () => {
+ expect(
+ resolveJobsNamespace(
+ config({ jobs: { namespace: 'ns-1' }, domain: 'puter.test' }),
+ ),
+ ).toBe('ns-1');
+ expect(resolveJobsNamespace(config({ domain: 'puter.test' }))).toBe(
+ 'puter.test',
+ );
+ expect(resolveJobsNamespace(config({}))).toBe('default');
+ });
+
+ it('falls back to "default" for a domain with a port, logging once', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const withPort = config({ domain: 'puter.localhost:4100' });
+ expect(resolveJobsNamespace(withPort)).toBe('default');
+ expect(resolveJobsNamespace(withPort)).toBe('default');
+ expect(error).toHaveBeenCalledTimes(1);
+ expect(String(error.mock.calls[0][0])).toContain(
+ 'puter.localhost:4100',
+ );
+ });
+
+ it('falls back to "default" for an invalid jobs.namespace', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(
+ resolveJobsNamespace(
+ config({ jobs: { namespace: 'a:b' }, domain: 'puter.test' }),
+ ),
+ ).toBe('default');
+ expect(resolveJobsNamespace(config({ jobs: { namespace: '' } }))).toBe(
+ 'default',
+ );
+ expect(
+ resolveJobsNamespace(
+ config({ jobs: { namespace: 'x'.repeat(129) } }),
+ ),
+ ).toBe('default');
+ expect(error).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/src/backend/util/jobsNamespace.ts b/src/backend/util/jobsNamespace.ts
new file mode 100644
index 000000000..78986d767
--- /dev/null
+++ b/src/backend/util/jobsNamespace.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { IConfig } from '../types.js';
+
+/** A namespace must fit the jobs and schedules tables and a Redis key segment. */
+const JOBS_NAMESPACE_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
+
+const warned = new Set();
+
+/**
+ * The namespace system jobs and schedules are keyed by, so deployments sharing
+ * a database or Redis stay apart: `jobs.namespace`, else `domain`, else
+ * `default`. One that can't be held (a domain with a port, say) falls back to
+ * `default`, with one logged error per value.
+ */
+export const resolveJobsNamespace = (config: IConfig): string => {
+ const configured = config.jobs?.namespace ?? config.domain ?? 'default';
+ if (JOBS_NAMESPACE_PATTERN.test(configured)) return configured;
+ if (!warned.has(configured)) {
+ warned.add(configured);
+ console.error(
+ `[jobs] namespace "${configured}" (jobs.namespace, else domain) must match ${JOBS_NAMESPACE_PATTERN}; using "default". Set jobs.namespace to keep deployments sharing a database apart.`,
+ );
+ }
+ return 'default';
+};